mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
Merge branch 'main' into feat/gemini-proxy-integration
# Conflicts: # src-tauri/src/proxy/providers/claude.rs # src-tauri/src/proxy/sse.rs # src-tauri/src/services/stream_check.rs
This commit is contained in:
@@ -174,6 +174,12 @@ pub struct InstalledSkill {
|
||||
pub apps: SkillApps,
|
||||
/// 安装时间(Unix 时间戳)
|
||||
pub installed_at: i64,
|
||||
/// 内容哈希(SHA-256,用于更新检测)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_hash: Option<String>,
|
||||
/// 最近更新时间(Unix 时间戳,0 = 从未更新)
|
||||
#[serde(default)]
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
|
||||
|
||||
+177
-61
@@ -1,9 +1,14 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::codex_oauth::CodexOAuthState;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthError;
|
||||
use crate::proxy::providers::copilot_auth::{
|
||||
CopilotAuthError, GitHubAccount, GitHubDeviceCodeResponse,
|
||||
};
|
||||
|
||||
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthAccount {
|
||||
@@ -34,9 +39,10 @@ pub struct ManagedAuthDeviceCodeResponse {
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> {
|
||||
fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> {
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
|
||||
AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH),
|
||||
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
|
||||
}
|
||||
}
|
||||
@@ -73,110 +79,220 @@ fn map_device_code_response(
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_start_login(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<ManagedAuthDeviceCodeResponse, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_poll_for_account(
|
||||
auth_provider: String,
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Option<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref())))
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
}))
|
||||
}
|
||||
Err(CopilotAuthError::AuthorizationPending) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(None)
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
}))
|
||||
}
|
||||
Err(CodexOAuthError::AuthorizationPending) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_list_accounts(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Vec<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_get_status(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<ManagedAuthStatus, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: status.migration_error,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect(),
|
||||
})
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: status.migration_error,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: None,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| {
|
||||
map_account(auth_provider, account, default_account_id.as_deref())
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_remove_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_set_default_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_logout(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => {
|
||||
let auth_manager = copilot_state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_CODEX_OAUTH => {
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
use crate::provider::UsageResult;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_balance(base_url: String, api_key: String) -> Result<UsageResult, String> {
|
||||
crate::services::balance::get_balance(&base_url, &api_key).await
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Codex OAuth Tauri Commands
|
||||
//!
|
||||
//! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。
|
||||
//!
|
||||
//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端,
|
||||
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。
|
||||
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Codex OAuth 认证状态
|
||||
pub struct CodexOAuthState(pub Arc<RwLock<CodexOAuthManager>>);
|
||||
|
||||
/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度
|
||||
///
|
||||
/// - `account_id` 未指定时回退到 `CodexOAuthManager` 的默认账号
|
||||
/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染
|
||||
/// - 复用 `services::subscription::query_codex_quota`,因此 wham/usage 端点协议
|
||||
/// 与 Codex CLI 路径完全一致
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn get_codex_oauth_quota(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, CodexOAuthState>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
let manager = state.0.read().await;
|
||||
|
||||
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
|
||||
let resolved = match account_id {
|
||||
Some(id) => Some(id),
|
||||
None => manager.default_account_id().await,
|
||||
};
|
||||
let Some(id) = resolved else {
|
||||
return Ok(SubscriptionQuota::not_found("codex_oauth"));
|
||||
};
|
||||
|
||||
// 获取(必要时自动刷新)access_token
|
||||
let token = match manager.get_valid_token_for_account(&id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return Ok(SubscriptionQuota::error(
|
||||
"codex_oauth",
|
||||
CredentialStatus::Expired,
|
||||
format!("Codex OAuth token unavailable: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(query_codex_quota(
|
||||
&token,
|
||||
Some(&id),
|
||||
"codex_oauth",
|
||||
"Codex OAuth access token expired or rejected. Please re-login via cc-switch.",
|
||||
)
|
||||
.await)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod auth;
|
||||
mod balance;
|
||||
mod codex_oauth;
|
||||
mod coding_plan;
|
||||
mod config;
|
||||
mod copilot;
|
||||
@@ -31,6 +33,8 @@ mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use auth::*;
|
||||
pub use balance::*;
|
||||
pub use codex_oauth::*;
|
||||
pub use coding_plan::*;
|
||||
pub use config::*;
|
||||
pub use copilot::*;
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::str::FromStr;
|
||||
// 常量定义
|
||||
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
|
||||
const TEMPLATE_TYPE_BALANCE: &str = "balance";
|
||||
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -268,6 +269,30 @@ pub async fn queryProviderUsage(
|
||||
});
|
||||
}
|
||||
|
||||
// ── 官方余额查询路径 ──
|
||||
if template_type == TEMPLATE_TYPE_BALANCE {
|
||||
let settings_config = provider
|
||||
.map(|p| &p.settings_config)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let env = settings_config.get("env");
|
||||
let base_url = env
|
||||
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let api_key = env
|
||||
.and_then(|e| {
|
||||
e.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| e.get("ANTHROPIC_API_KEY"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
return crate::services::balance::get_balance(base_url, api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query balance: {e}"));
|
||||
}
|
||||
|
||||
// ── 通用 JS 脚本路径 ──
|
||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||
.await
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||
use crate::error::format_skill_error;
|
||||
use crate::services::skill::{
|
||||
DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService,
|
||||
SkillUninstallResult,
|
||||
DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo,
|
||||
SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo,
|
||||
SkillsShSearchResult,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use std::sync::Arc;
|
||||
@@ -134,6 +135,54 @@ pub async fn discover_available_skills(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 检查 Skills 更新
|
||||
#[tauri::command]
|
||||
pub async fn check_skill_updates(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<SkillUpdateInfo>, String> {
|
||||
service
|
||||
.0
|
||||
.check_updates(&app_state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新单个 Skill
|
||||
#[tauri::command]
|
||||
pub async fn update_skill(
|
||||
id: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<InstalledSkill, String> {
|
||||
service
|
||||
.0
|
||||
.update_skill(&app_state.db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 迁移 Skill 存储位置
|
||||
#[tauri::command]
|
||||
pub async fn migrate_skill_storage(
|
||||
target: SkillStorageLocation,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<MigrationResult, String> {
|
||||
SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 搜索 skills.sh 公共目录
|
||||
#[tauri::command]
|
||||
pub async fn search_skills_sh(
|
||||
query: String,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<SkillsShSearchResult, String> {
|
||||
SkillService::search_skills_sh(&query, limit, offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 兼容旧 API 的命令 ==========
|
||||
|
||||
/// 获取技能列表(兼容旧 API)
|
||||
|
||||
@@ -11,8 +11,11 @@ pub fn get_usage_summary(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
state.db.get_usage_summary(start_date, end_date)
|
||||
state
|
||||
.db
|
||||
.get_usage_summary(start_date, end_date, app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取每日趋势
|
||||
@@ -21,20 +24,29 @@ pub fn get_usage_trends(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
state.db.get_daily_trends(start_date, end_date)
|
||||
state
|
||||
.db
|
||||
.get_daily_trends(start_date, end_date, app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
#[tauri::command]
|
||||
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats()
|
||||
pub fn get_provider_stats(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats(app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
#[tauri::command]
|
||||
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats()
|
||||
pub fn get_model_stats(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats(app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
@@ -166,6 +178,51 @@ pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 手动触发会话日志同步
|
||||
#[tauri::command]
|
||||
pub fn sync_session_usage(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
|
||||
// 同步 Claude 会话日志
|
||||
let mut result = crate::services::session_usage::sync_claude_session_logs(&state.db)?;
|
||||
|
||||
// 同步 Codex 使用数据
|
||||
match crate::services::session_usage_codex::sync_codex_usage(&state.db) {
|
||||
Ok(codex_result) => {
|
||||
result.imported += codex_result.imported;
|
||||
result.skipped += codex_result.skipped;
|
||||
result.files_scanned += codex_result.files_scanned;
|
||||
result.errors.extend(codex_result.errors);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("Codex 同步失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 Gemini 使用数据
|
||||
match crate::services::session_usage_gemini::sync_gemini_usage(&state.db) {
|
||||
Ok(gemini_result) => {
|
||||
result.imported += gemini_result.imported;
|
||||
result.skipped += gemini_result.skipped;
|
||||
result.files_scanned += gemini_result.files_scanned;
|
||||
result.errors.extend(gemini_result.errors);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("Gemini 同步失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取数据来源分布
|
||||
#[tauri::command]
|
||||
pub fn get_usage_data_sources(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<crate::services::session_usage::DataSourceSummary>, AppError> {
|
||||
crate::services::session_usage::get_data_source_breakdown(&state.db)
|
||||
}
|
||||
|
||||
/// 模型定价信息
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod providers_seed;
|
||||
pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::error::AppError;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
type OmoProviderRow = (
|
||||
String,
|
||||
@@ -501,4 +501,136 @@ impl Database {
|
||||
in_failover_queue: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 判断 providers 表是否为空(全 app_type 一起算)。
|
||||
///
|
||||
/// 用于区分"全新安装"和"升级用户":在启动流程 import/seed 之前调用。
|
||||
/// 使用 `EXISTS` 短路查询,比 `COUNT(*)` 在将来表变大时更高效。
|
||||
pub fn is_providers_empty(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let exists: bool = conn
|
||||
.query_row("SELECT EXISTS(SELECT 1 FROM providers)", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(!exists)
|
||||
}
|
||||
|
||||
/// 仅获取指定 app 下所有 provider 的 id 集合。
|
||||
///
|
||||
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询。
|
||||
/// 用于只需要做存在性检查的场景(如 additive 模式的 live 同步去重)。
|
||||
pub fn get_provider_ids(&self, app_type: &str) -> Result<HashSet<String>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM providers WHERE app_type = ?1")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map(params![app_type], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let mut ids = HashSet::new();
|
||||
for row in rows {
|
||||
ids.insert(row.map_err(|e| AppError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// 判断指定 app 下是否存在非官方种子的供应商。
|
||||
///
|
||||
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询、首条命中即返回。
|
||||
/// 用于 `import_default_config` 决定是否跳过 live 导入。
|
||||
pub fn has_non_official_seed_provider(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
use crate::database::dao::providers_seed::is_official_seed_id;
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM providers WHERE app_type = ?1")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let mut rows = stmt
|
||||
.query(params![app_type])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||
let id: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if !is_official_seed_id(&id) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// 计算指定 app 下一个可用的 sort_index(追加到末尾)。
|
||||
fn next_sort_index_for_app(&self, app_type: &str) -> Result<usize, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let max: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT MAX(sort_index) FROM providers WHERE app_type = ?1",
|
||||
params![app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(max.map(|v| (v + 1) as usize).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 启动时调用:补齐缺失的官方预设供应商(Claude / Codex / Gemini)。
|
||||
///
|
||||
/// 使用 settings flag `official_providers_seeded` 保证每个数据库只执行一次:
|
||||
/// - 全新用户:seed 三条官方预设
|
||||
/// - 老用户升级:同样会触发一次(flag 不存在),追加到末尾,不影响已有排序
|
||||
/// - 用户删除 seed 后:不再重建(flag 已为 true),尊重用户意图
|
||||
///
|
||||
/// 与 `Database::save_provider` 的 UPSERT 语义配合,即使被意外重复调用
|
||||
/// 也不会覆盖用户当前激活的供应商(is_current 字段会被保留)。
|
||||
pub fn init_default_official_providers(&self) -> Result<usize, AppError> {
|
||||
use crate::database::dao::providers_seed::OFFICIAL_SEEDS;
|
||||
|
||||
if self
|
||||
.get_bool_flag("official_providers_seeded")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut inserted = 0_usize;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
for seed in OFFICIAL_SEEDS {
|
||||
let app_type_str = seed.app_type.as_str();
|
||||
|
||||
// 若该 id 已存在(极端情况:用户曾手动用过同 id),跳过
|
||||
if self.get_provider_by_id(seed.id, app_type_str)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let next_sort_index = self.next_sort_index_for_app(app_type_str)?;
|
||||
|
||||
let settings_config: serde_json::Value =
|
||||
serde_json::from_str(seed.settings_config_json).map_err(|e| {
|
||||
AppError::Database(format!("Seed JSON parse failed for {}: {e}", seed.id))
|
||||
})?;
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
seed.id.to_string(),
|
||||
seed.name.to_string(),
|
||||
settings_config,
|
||||
Some(seed.website_url.to_string()),
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
provider.icon = Some(seed.icon.to_string());
|
||||
provider.icon_color = Some(seed.icon_color.to_string());
|
||||
provider.sort_index = Some(next_sort_index);
|
||||
provider.created_at = Some(now_ms);
|
||||
|
||||
self.save_provider(app_type_str, &provider)?;
|
||||
inserted += 1;
|
||||
log::info!(
|
||||
"✓ Seeded official provider: {} ({})",
|
||||
seed.name,
|
||||
app_type_str
|
||||
);
|
||||
}
|
||||
|
||||
// 即使 inserted=0(例如用户手动创建过同 id)也设置 flag 防止反复检查
|
||||
self.set_setting("official_providers_seeded", "true")?;
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 官方供应商种子数据
|
||||
//!
|
||||
//! 启动时调用 `Database::init_default_official_providers` 把这些条目
|
||||
//! 写入 `providers` 表,让所有用户都能看到一个"一键切回官方"的入口。
|
||||
//!
|
||||
//! 字段与前端预设保持一致,参见:
|
||||
//! - `src/config/claudeProviderPresets.ts`("Claude Official")
|
||||
//! - `src/config/codexProviderPresets.ts`("OpenAI Official")
|
||||
//! - `src/config/geminiProviderPresets.ts`("Google Official")
|
||||
|
||||
use crate::app_config::AppType;
|
||||
|
||||
/// 单条官方供应商种子定义。
|
||||
pub(crate) struct OfficialProviderSeed {
|
||||
pub id: &'static str,
|
||||
pub app_type: AppType,
|
||||
pub name: &'static str,
|
||||
pub website_url: &'static str,
|
||||
pub icon: &'static str,
|
||||
pub icon_color: &'static str,
|
||||
/// settings_config 的 JSON 字符串,每个 app 结构不同。
|
||||
pub settings_config_json: &'static str,
|
||||
}
|
||||
|
||||
/// Claude / Codex / Gemini 三个应用的官方预设。
|
||||
///
|
||||
/// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。
|
||||
pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
OfficialProviderSeed {
|
||||
id: "claude-official",
|
||||
app_type: AppType::Claude,
|
||||
name: "Claude Official",
|
||||
website_url: "https://www.anthropic.com/claude-code",
|
||||
icon: "anthropic",
|
||||
icon_color: "#D4915D",
|
||||
// 空 env 让用户走 Claude CLI 默认认证流程
|
||||
settings_config_json: r#"{"env":{}}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: "codex-official",
|
||||
app_type: AppType::Codex,
|
||||
name: "OpenAI Official",
|
||||
website_url: "https://chatgpt.com/codex",
|
||||
icon: "openai",
|
||||
icon_color: "#00A67E",
|
||||
// 空 auth + 空 config 让用户走 ChatGPT Plus/Pro OAuth
|
||||
settings_config_json: r#"{"auth":{},"config":""}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: "gemini-official",
|
||||
app_type: AppType::Gemini,
|
||||
name: "Google Official",
|
||||
website_url: "https://ai.google.dev/",
|
||||
icon: "gemini",
|
||||
icon_color: "#4285F4",
|
||||
// 空 env + 空 config 让用户走 Google OAuth
|
||||
settings_config_json: r#"{"env":{},"config":{}}"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// 判断给定的 provider id 是否属于内置官方种子。
|
||||
///
|
||||
/// 单一事实源:直接扫描 `OFFICIAL_SEEDS`,避免在多处重复维护 id 列表。
|
||||
pub(crate) fn is_official_seed_id(id: &str) -> bool {
|
||||
OFFICIAL_SEEDS.iter().any(|seed| seed.id == id)
|
||||
}
|
||||
@@ -33,6 +33,18 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// 以布尔语义读取 flag:`"true"` 或 `"1"` → true,其它全部 false。
|
||||
///
|
||||
/// 用于一次性启动 flag(`official_providers_seeded` / `first_run_notice_shown` 等)。
|
||||
/// 与 `is_legacy_common_config_migrated` 等只认 `"true"` 的历史辅助函数**不同**——
|
||||
/// 这里同时接受 `"1"` 是为了兼容 `init_default_official_providers` 既有写法。
|
||||
pub fn get_bool_flag(&self, key: &str) -> Result<bool, AppError> {
|
||||
Ok(matches!(
|
||||
self.get_setting(key)?.as_deref(),
|
||||
Some("true") | Some("1")
|
||||
))
|
||||
}
|
||||
|
||||
/// 设置值
|
||||
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -22,7 +22,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
installed_at, content_hash, updated_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -45,6 +46,8 @@ impl Database {
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
installed_at: row.get(12)?,
|
||||
content_hash: row.get(13)?,
|
||||
updated_at: row.get::<_, i64>(14).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -63,7 +66,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -85,6 +89,8 @@ impl Database {
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
installed_at: row.get(12)?,
|
||||
content_hash: row.get(13)?,
|
||||
updated_at: row.get::<_, i64>(14).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -101,8 +107,9 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -117,6 +124,8 @@ impl Database {
|
||||
skill.apps.gemini,
|
||||
skill.apps.opencode,
|
||||
skill.installed_at,
|
||||
skill.content_hash,
|
||||
skill.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -152,6 +161,23 @@ impl Database {
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 更新 Skill 的内容哈希和更新时间
|
||||
pub fn update_skill_hash(
|
||||
&self,
|
||||
id: &str,
|
||||
content_hash: &str,
|
||||
updated_at: i64,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET content_hash = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![content_hash, updated_at, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
// ========== SkillRepo CRUD(保持原有) ==========
|
||||
|
||||
/// 获取所有 Skill 仓库
|
||||
|
||||
@@ -44,7 +44,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 6;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 8;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -93,7 +93,9 @@ impl Database {
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -187,7 +189,8 @@ impl Database {
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||||
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||||
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL,
|
||||
data_source TEXT NOT NULL DEFAULT 'proxy'
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
|
||||
@@ -269,6 +272,18 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 18. Session Log Sync 表 (会话日志同步状态)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS session_log_sync (
|
||||
file_path TEXT PRIMARY KEY,
|
||||
last_modified INTEGER NOT NULL,
|
||||
last_line_offset INTEGER NOT NULL DEFAULT 0,
|
||||
last_synced_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -393,6 +408,16 @@ impl Database {
|
||||
Self::migrate_v5_to_v6(conn)?;
|
||||
Self::set_user_version(conn, 6)?;
|
||||
}
|
||||
6 => {
|
||||
log::info!("迁移数据库从 v6 到 v7(Skills 更新检测支持)");
|
||||
Self::migrate_v6_to_v7(conn)?;
|
||||
Self::set_user_version(conn, 7)?;
|
||||
}
|
||||
7 => {
|
||||
log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)");
|
||||
Self::migrate_v7_to_v8(conn)?;
|
||||
Self::set_user_version(conn, 8)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1045,6 +1070,71 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v6 -> v7: Skills 更新检测支持(content_hash + updated_at)
|
||||
fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> {
|
||||
Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "skills", "updated_at", "INTEGER NOT NULL DEFAULT 0")?;
|
||||
log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v7 -> v8: 会话日志使用追踪(无代理模式统计支持)
|
||||
fn migrate_v7_to_v8(conn: &Connection) -> Result<(), AppError> {
|
||||
// 1. 为 proxy_request_logs 添加 data_source 列,区分数据来源
|
||||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_request_logs",
|
||||
"data_source",
|
||||
"TEXT NOT NULL DEFAULT 'proxy'",
|
||||
)?;
|
||||
}
|
||||
|
||||
// 2. 创建会话日志同步状态表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS session_log_sync (
|
||||
file_path TEXT PRIMARY KEY,
|
||||
last_modified INTEGER NOT NULL,
|
||||
last_line_offset INTEGER NOT NULL DEFAULT 0,
|
||||
last_synced_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?;
|
||||
|
||||
// 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD
|
||||
let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[
|
||||
("deepseek-v3.2", "0.28", "0.42", "0.028", "0"),
|
||||
("deepseek-v3.1", "0.55", "1.67", "0.055", "0"),
|
||||
("deepseek-v3", "0.28", "1.11", "0.028", "0"),
|
||||
("doubao-seed-code", "0.17", "1.11", "0.02", "0"),
|
||||
("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"),
|
||||
("kimi-k2-0905", "0.55", "2.20", "0.10", "0"),
|
||||
("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"),
|
||||
("minimax-m2.1", "0.27", "0.95", "0.03", "0"),
|
||||
("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"),
|
||||
("minimax-m2", "0.27", "0.95", "0.03", "0"),
|
||||
("glm-4.7", "0.39", "1.75", "0.04", "0"),
|
||||
("glm-4.6", "0.28", "1.11", "0.03", "0"),
|
||||
("mimo-v2-flash", "0.09", "0.29", "0.009", "0"),
|
||||
];
|
||||
for (model_id, input, output, cache_read, cache_creation) in pricing_fixes {
|
||||
conn.execute(
|
||||
"UPDATE model_pricing SET
|
||||
input_cost_per_million = ?2,
|
||||
output_cost_per_million = ?3,
|
||||
cache_read_cost_per_million = ?4,
|
||||
cache_creation_cost_per_million = ?5
|
||||
WHERE model_id = ?1",
|
||||
rusqlite::params![model_id, input, output, cache_read, cache_creation],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -1134,6 +1224,10 @@ impl Database {
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// GPT-5.4 系列
|
||||
("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
|
||||
("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
|
||||
("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"),
|
||||
// GPT-5.2 系列
|
||||
("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
@@ -1294,6 +1388,30 @@ impl Database {
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
// OpenAI Reasoning 系列
|
||||
("o3", "OpenAI o3", "2", "8", "0.50", "0"),
|
||||
("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"),
|
||||
// GPT-4.1 系列
|
||||
("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
|
||||
("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
|
||||
("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
|
||||
// Gemini 3.1 系列
|
||||
(
|
||||
"gemini-3.1-pro-preview",
|
||||
"Gemini 3.1 Pro Preview",
|
||||
"2",
|
||||
"12",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
"Gemini 3.1 Flash Lite Preview",
|
||||
"0.25",
|
||||
"1.50",
|
||||
"0.025",
|
||||
"0",
|
||||
),
|
||||
// Gemini 3 系列
|
||||
(
|
||||
"gemini-3-pro-preview",
|
||||
@@ -1328,6 +1446,23 @@ impl Database {
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gemini-2.5-flash-lite",
|
||||
"Gemini 2.5 Flash Lite",
|
||||
"0.10",
|
||||
"0.40",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
// Gemini 2.0 系列
|
||||
(
|
||||
"gemini-2.0-flash",
|
||||
"Gemini 2.0 Flash",
|
||||
"0.10",
|
||||
"0.40",
|
||||
"0.025",
|
||||
"0",
|
||||
),
|
||||
// StepFun 系列
|
||||
(
|
||||
"step-3.5-flash",
|
||||
@@ -1337,68 +1472,92 @@ impl Database {
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
// ====== 国产模型 (CNY/1M tokens) ======
|
||||
// ====== 国产模型 (USD/1M tokens) ======
|
||||
// Doubao (字节跳动)
|
||||
(
|
||||
"doubao-seed-code",
|
||||
"Doubao Seed Code",
|
||||
"1.20",
|
||||
"8.00",
|
||||
"0.24",
|
||||
"0.17",
|
||||
"1.11",
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek 系列
|
||||
(
|
||||
"deepseek-v3.2",
|
||||
"DeepSeek V3.2",
|
||||
"2.00",
|
||||
"3.00",
|
||||
"0.40",
|
||||
"0.28",
|
||||
"0.42",
|
||||
"0.028",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-v3.1",
|
||||
"DeepSeek V3.1",
|
||||
"4.00",
|
||||
"12.00",
|
||||
"0.80",
|
||||
"0.55",
|
||||
"1.67",
|
||||
"0.055",
|
||||
"0",
|
||||
),
|
||||
("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
|
||||
(
|
||||
"deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
"0.28",
|
||||
"0.42",
|
||||
"0.028",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.28",
|
||||
"0.42",
|
||||
"0.028",
|
||||
"0",
|
||||
),
|
||||
("deepseek-v3", "DeepSeek V3", "2.00", "8.00", "0.40", "0"),
|
||||
// Kimi (月之暗面)
|
||||
(
|
||||
"kimi-k2-thinking",
|
||||
"Kimi K2 Thinking",
|
||||
"4.00",
|
||||
"16.00",
|
||||
"1.00",
|
||||
"0.55",
|
||||
"2.20",
|
||||
"0.10",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2-0905", "Kimi K2", "4.00", "16.00", "1.00", "0"),
|
||||
("kimi-k2-0905", "Kimi K2", "0.55", "2.20", "0.10", "0"),
|
||||
(
|
||||
"kimi-k2-turbo",
|
||||
"Kimi K2 Turbo",
|
||||
"8.00",
|
||||
"58.00",
|
||||
"1.00",
|
||||
"1.11",
|
||||
"8.06",
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
|
||||
// MiniMax 系列
|
||||
("minimax-m2.1", "MiniMax M2.1", "2.10", "8.40", "0.21", "0"),
|
||||
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
|
||||
(
|
||||
"minimax-m2.1-lightning",
|
||||
"MiniMax M2.1 Lightning",
|
||||
"2.10",
|
||||
"16.80",
|
||||
"0.21",
|
||||
"0.27",
|
||||
"2.33",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
("minimax-m2", "MiniMax M2", "2.10", "8.40", "0.21", "0"),
|
||||
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "2.00", "8.00", "0.40", "0"),
|
||||
("glm-4.6", "GLM-4.6", "2.00", "8.00", "0.40", "0"),
|
||||
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
|
||||
// Mimo (小米)
|
||||
("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "0"),
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
"Mimo V2 Flash",
|
||||
"0.09",
|
||||
"0.29",
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
];
|
||||
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
|
||||
@@ -13,6 +13,8 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_fix;
|
||||
mod mcp;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
@@ -132,6 +134,10 @@ fn handle_deeplink_url(
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_fix::nudge_main_window(window.clone());
|
||||
}
|
||||
log::info!("✓ Window shown and focused");
|
||||
}
|
||||
}
|
||||
@@ -229,6 +235,10 @@ pub fn run() {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_fix::nudge_main_window(window.clone());
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -457,6 +467,80 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||
}
|
||||
|
||||
// 1.5. 自动导入 live 配置 + seed 官方预设供应商(Claude / Codex / Gemini)
|
||||
//
|
||||
// 先 import 后 seed 是有意为之:先把用户手动配置的 settings.json / auth.json / .env
|
||||
// 落成 "default" provider 设为 current,再追加官方预设(is_current=false)。
|
||||
// 这样用户切到官方预设时,回填机制会保护原 live 配置不丢失。
|
||||
//
|
||||
// 捕获首次运行快照:所有全新装用户都会看到欢迎弹窗介绍 CC Switch 的工作方式。
|
||||
// 读失败时默认不弹,宁可漏弹也不要因为故障打扰用户。
|
||||
let first_run_already_confirmed = crate::settings::get_settings()
|
||||
.first_run_notice_confirmed
|
||||
.unwrap_or(false);
|
||||
let fresh_install_at_startup =
|
||||
app_state.db.is_providers_empty().unwrap_or(false);
|
||||
|
||||
for app_type in
|
||||
crate::app_config::AppType::all().filter(|t| !t.is_additive_mode())
|
||||
{
|
||||
match crate::services::provider::import_default_config(
|
||||
&app_state,
|
||||
app_type.clone(),
|
||||
) {
|
||||
Ok(true) => log::info!(
|
||||
"✓ Imported live config for {} as default provider",
|
||||
app_type.as_str()
|
||||
),
|
||||
Ok(false) => log::debug!(
|
||||
"○ {} already has providers; live import skipped",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::debug!(
|
||||
"○ No live config to import for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
match app_state.db.init_default_official_providers() {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Seeded {count} official provider(s)");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("✗ Failed to seed official providers: {e}"),
|
||||
}
|
||||
|
||||
// 老用户 / 已确认的路径由 `fresh_install_at_startup` 自行拦截,这里不做写入。
|
||||
// 字段只由前端在用户点击"我知道了"时 save_settings 回写,语义是"用户显式确认过"。
|
||||
if !first_run_already_confirmed && fresh_install_at_startup {
|
||||
log::info!("✓ First-run welcome notice pending");
|
||||
}
|
||||
|
||||
// 1.6. 自动同步 OpenCode / OpenClaw 的 live providers 到数据库
|
||||
//
|
||||
// additive 模式(OpenCode / OpenClaw)的 import 函数本身按 id 幂等,
|
||||
// 已有的 provider 会被跳过,所以每次启动都跑是安全的——既保证新装
|
||||
// 用户开箱可见 live 中的供应商,也让外部修改的 live 文件能在重启
|
||||
// 后同步到数据库(与之前依赖前端"导入当前配置"按钮手动触发不同)。
|
||||
//
|
||||
// 底层 read_*_config 在文件不存在时返回默认空配置,因此新装且无
|
||||
// live 文件的用户走 Ok(0) 路径,不会产生错误日志噪音。
|
||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No new OpenCode providers to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"),
|
||||
}
|
||||
|
||||
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
{
|
||||
let has_omo = app_state
|
||||
@@ -715,6 +799,18 @@ pub fn run() {
|
||||
log::info!("✓ CopilotAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代)
|
||||
{
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use commands::CodexOAuthState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let codex_oauth_manager = CodexOAuthManager::new(app_config_dir);
|
||||
app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager))));
|
||||
log::info!("✓ CodexOAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
@@ -796,6 +892,65 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Session log usage sync: 启动时同步一次,之后每 60 秒检查
|
||||
let db_for_session_sync = state.db.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
const SESSION_SYNC_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
// 首次同步
|
||||
if let Err(e) =
|
||||
crate::services::session_usage::sync_claude_session_logs(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Session usage initial sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_codex::sync_codex_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Codex usage initial sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Gemini usage initial sync failed: {e}");
|
||||
}
|
||||
|
||||
// 定期同步
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
SESSION_SYNC_INTERVAL_SECS,
|
||||
));
|
||||
interval.tick().await; // skip immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) =
|
||||
crate::services::session_usage::sync_claude_session_logs(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Session usage periodic sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_codex::sync_codex_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Codex usage periodic sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Gemini usage periodic sync failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||
@@ -828,6 +983,14 @@ pub fn run() {
|
||||
// 正常启动模式:显示窗口
|
||||
let _ = window.show();
|
||||
log::info!("正常启动模式:主窗口已显示");
|
||||
|
||||
// Linux: 解决首次启动 UI 无响应问题(Tauri #10746 + wry #637)。
|
||||
// 启动时 webview 未获取焦点 + surface 尺寸协商失败,导致点击无效。
|
||||
// 这里做 set_focus + 伪 resize,等价于无视觉版本的"最大化-还原"。
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_fix::nudge_main_window(window.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,7 +1055,9 @@ pub fn run() {
|
||||
commands::testUsageScript,
|
||||
// subscription quota
|
||||
commands::get_subscription_quota,
|
||||
commands::get_codex_oauth_quota,
|
||||
commands::get_coding_plan_quota,
|
||||
commands::get_balance,
|
||||
// New MCP via config.json (SSOT)
|
||||
commands::get_mcp_config,
|
||||
commands::upsert_mcp_server_in_config,
|
||||
@@ -962,6 +1127,10 @@ pub fn run() {
|
||||
commands::scan_unmanaged_skills,
|
||||
commands::import_skills_from_apps,
|
||||
commands::discover_available_skills,
|
||||
commands::check_skill_updates,
|
||||
commands::update_skill,
|
||||
commands::migrate_skill_storage,
|
||||
commands::search_skills_sh,
|
||||
// Skill management (legacy API compatibility)
|
||||
commands::get_skills,
|
||||
commands::get_skills_for_app,
|
||||
@@ -1020,6 +1189,9 @@ pub fn run() {
|
||||
commands::update_model_pricing,
|
||||
commands::delete_model_pricing,
|
||||
commands::check_provider_limits,
|
||||
// Session usage sync
|
||||
commands::sync_session_usage,
|
||||
commands::get_usage_data_sources,
|
||||
// Stream health check
|
||||
commands::stream_check_provider,
|
||||
commands::stream_check_all_providers,
|
||||
|
||||
@@ -36,6 +36,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
crate::linux_fix::nudge_main_window(window.clone());
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
@@ -66,6 +70,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
crate::linux_fix::nudge_main_window(window.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Linux 专用的主窗口恢复补丁。
|
||||
//!
|
||||
//! 解决 Tauri 2.x 在部分 Linux 发行版(尤其是 Wayland / 某些 WebKitGTK
|
||||
//! 版本)上启动后 UI 无法响应点击的问题:
|
||||
//!
|
||||
//! - **失效模式 A**(Tauri #10746 / wry #637):webview 在 `show()` 后
|
||||
//! 没有获得 keyboard focus,导致首次点击被 X11/Wayland 用作
|
||||
//! click-to-activate 而非传给 webview。
|
||||
//! - **失效模式 B**:GTK surface 与 WebKitWebView 的 input region 尺寸
|
||||
//! 协商在 `visible:false` → `show()` 的路径上失败,整窗永远不响应
|
||||
//! 点击,只有重新 `size_allocate`(例如最大化-还原)才能恢复。
|
||||
//!
|
||||
//! 本模块导出 [`nudge_main_window`],它通过「显式 set_focus + 无视觉
|
||||
//! 版本的 ±1px 伪 resize」精确模拟用户手动最大化再还原的 workaround,
|
||||
//! 但肉眼无法察觉。所有"让主窗口出现在用户面前"的路径(正常启动、
|
||||
//! deeplink 唤起、single_instance 回调、托盘 show_main、lightweight
|
||||
//! 退出)都应在现有 `set_focus()` 之后追加一次调用。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{PhysicalSize, WebviewWindow};
|
||||
|
||||
/// 在 webview realize 之后的延迟,等 GTK 主循环把 realize 事件处理完。
|
||||
/// 200ms 是社区经验值;太短 set_focus 仍会无效,太长会让首屏可交互
|
||||
/// 时间被肉眼感知到。
|
||||
const REALIZE_WAIT: Duration = Duration::from_millis(200);
|
||||
|
||||
/// ±1px 伪 resize 两步之间的间隔,确保 GTK 先处理了第一次
|
||||
/// `size_allocate` 再收到第二次 resize。放宽到 100ms 是因为 Tao 在 Linux
|
||||
/// 上的尺寸 API 是异步的(底层走 `gtk_window_resize` → 合成器 configure),
|
||||
/// 太短会让合成器把两次连续 resize coalesce 成一次。
|
||||
const RESIZE_GAP: Duration = Duration::from_millis(100);
|
||||
|
||||
/// 尺寸对账回读前的额外等待。200ms + 100ms + 500ms = 总共 ~800ms 后
|
||||
/// 校验窗口尺寸是否回到 original。这个时间足够所有合成器处理完
|
||||
/// resize 消息队列。
|
||||
const RECONCILE_WAIT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// 对主窗口执行 Linux 专用的「focus + surface 重激活」序列。
|
||||
///
|
||||
/// 调用是 fire-and-forget:内部 spawn 一个异步任务在 ~250ms 后完成。
|
||||
/// 调用线程立即返回,不阻塞 UI。
|
||||
pub(crate) fn nudge_main_window(window: WebviewWindow) {
|
||||
// 第一次 set_focus:webview 可能还没 realize,这一次通常是无效的,
|
||||
// 但成本极低(线程安全,内部 run_on_main_thread),顺手做掉。
|
||||
let _ = window.set_focus();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(REALIZE_WAIT).await;
|
||||
|
||||
// 第二次 set_focus:此时 webview realize 已完成,在绝大多数
|
||||
// 发行版上这一次会真的生效,消除失效模式 A。
|
||||
let _ = window.set_focus();
|
||||
|
||||
// 伪 resize:读取当前 inner_size,先加 1px 再还原。这会触发
|
||||
// GTK 的 size-allocate → WebKitWebViewBase::size_allocate →
|
||||
// 重新 attach input surface,消除失效模式 B。
|
||||
//
|
||||
// 使用 PhysicalSize 避免跨 DPI 的逻辑坐标漂移;saturating_add
|
||||
// 防止极端尺寸溢出。
|
||||
match window.inner_size() {
|
||||
Ok(original) => {
|
||||
let bumped = PhysicalSize::new(original.width.saturating_add(1), original.height);
|
||||
let _ = window.set_size(bumped);
|
||||
tokio::time::sleep(RESIZE_GAP).await;
|
||||
let _ = window.set_size(original);
|
||||
log::info!("Linux: 已对主窗口执行 focus + surface 重激活");
|
||||
|
||||
// 尺寸对账回读:Tao Linux 的尺寸 API 是异步的,`set_size` 只是把
|
||||
// resize 请求送进 GTK 主循环队列,合成器可能会 coalesce 两次连续
|
||||
// 请求(尤其是第二次 `set_size(original)`),导致窗口永久停留在
|
||||
// width+1。这里等合成器处理完队列后读一次实际尺寸,发现 drift 就
|
||||
// 再补一次 `set_size(original)` 兜底。
|
||||
//
|
||||
// 已知限制:tiling Wayland 合成器(sway/river/hyprland)会完全忽略
|
||||
// `set_size`,此时对账永远 drift=0(因为两次 set_size 都是 no-op),
|
||||
// 看起来"没问题"但失效模式 B 其实没被修复;这是已知限制,需要用户
|
||||
// 侧用 GDK_BACKEND=x11 绕过,README 应该有说明。
|
||||
tokio::time::sleep(RECONCILE_WAIT).await;
|
||||
match window.inner_size() {
|
||||
Ok(after) => {
|
||||
if after.width != original.width || after.height != original.height {
|
||||
log::info!(
|
||||
"Linux nudge 尺寸 drift: expected={}x{}, got={}x{},已补偿",
|
||||
original.width,
|
||||
original.height,
|
||||
after.width,
|
||||
after.height
|
||||
);
|
||||
let _ = window.set_size(original);
|
||||
// 最终校验:如果补偿后仍然不一致,记 warn 让用户/开发者
|
||||
// 知道对账失败。这时窗口会停在非预期尺寸(通常是 +1px),
|
||||
// 属于极端兜底场景。
|
||||
if let Ok(final_size) = window.inner_size() {
|
||||
if final_size.width != original.width
|
||||
|| final_size.height != original.height
|
||||
{
|
||||
log::warn!(
|
||||
"Linux nudge 尺寸 drift 补偿后仍不一致: expected={}x{}, got={}x{}",
|
||||
original.width,
|
||||
original.height,
|
||||
final_size.width,
|
||||
final_size.height
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Linux nudge: 对账回读 inner_size 失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 极罕见的失败路径;只做了 set_focus 也比什么都不做强,
|
||||
// 不要让 resize 失败把整个补丁吞掉。
|
||||
log::warn!("Linux nudge: 读取 inner_size 失败,跳过伪 resize: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,12 @@ fn main() {
|
||||
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
// 禁用 WebKitGTK 合成模式,规避 resize 时 webview 崩溃以及部分 Wayland
|
||||
// 合成器下的 surface 协商问题(整窗 UI 点击无响应、必须最大化-还原才能恢复)。
|
||||
// 参考: https://github.com/tauri-apps/tauri/issues/9394
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
}
|
||||
}
|
||||
|
||||
cc_switch_lib::run();
|
||||
|
||||
@@ -20,7 +20,8 @@ use super::{
|
||||
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::commands::CopilotAuthState;
|
||||
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use http::Extensions;
|
||||
@@ -935,6 +936,9 @@ impl RequestForwarder {
|
||||
let force_identity_encoding = needs_transform
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
@@ -987,11 +991,71 @@ impl RequestForwarder {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Codex OAuth 特殊处理:从 CodexOAuthManager 获取真实 access_token
|
||||
if auth.strategy == AuthStrategy::CodexOAuth {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
let codex_state = app_handle.state::<CodexOAuthState>();
|
||||
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
|
||||
codex_state.0.read().await;
|
||||
|
||||
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("codex_oauth"));
|
||||
|
||||
let token_result = match &account_id {
|
||||
Some(id) => {
|
||||
log::debug!("[CodexOAuth] 使用指定账号 {id} 获取 token");
|
||||
codex_auth.get_valid_token_for_account(id).await
|
||||
}
|
||||
None => {
|
||||
log::debug!("[CodexOAuth] 使用默认账号获取 token");
|
||||
codex_auth.get_valid_token().await
|
||||
}
|
||||
};
|
||||
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
auth = AuthInfo::new(token, AuthStrategy::CodexOAuth);
|
||||
// 解析使用的 account_id(用于注入 ChatGPT-Account-Id header)
|
||||
codex_oauth_account_id = match account_id {
|
||||
Some(id) => Some(id),
|
||||
None => codex_auth.default_account_id().await,
|
||||
};
|
||||
log::debug!(
|
||||
"[CodexOAuth] 成功获取 access_token (account={})",
|
||||
codex_oauth_account_id.as_deref().unwrap_or("default")
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CodexOAuth] 获取 access_token 失败: {e}");
|
||||
return Err(ProxyError::AuthError(format!(
|
||||
"Codex OAuth 认证失败: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("[CodexOAuth] AppHandle 不可用");
|
||||
return Err(ProxyError::AuthError(
|
||||
"Codex OAuth 认证不可用(无 AppHandle)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
adapter.get_auth_headers(&auth)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 注入 Codex OAuth 的 ChatGPT-Account-Id header(如果有 account_id)
|
||||
if let Some(ref account_id) = codex_oauth_account_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(account_id) {
|
||||
auth_headers.push((http::HeaderName::from_static("chatgpt-account-id"), hv));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
|
||||
for (name, value) in auth_headers.iter_mut() {
|
||||
|
||||
@@ -119,6 +119,15 @@ pub enum AuthStrategy {
|
||||
///
|
||||
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
|
||||
GitHubCopilot,
|
||||
|
||||
/// Codex OAuth 认证方式(ChatGPT Plus/Pro)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <access_token>`
|
||||
/// - Header: `ChatGPT-Account-Id: <account_id>` (来自 forwarder 注入)
|
||||
/// - Header: `originator: cc-switch`
|
||||
///
|
||||
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -234,6 +243,7 @@ mod tests {
|
||||
AuthStrategy::Google,
|
||||
AuthStrategy::GoogleOAuth,
|
||||
AuthStrategy::GitHubCopilot,
|
||||
AuthStrategy::CodexOAuth,
|
||||
];
|
||||
|
||||
for (i, s1) in strategies.iter().enumerate() {
|
||||
|
||||
@@ -23,6 +23,13 @@ use crate::proxy::error::ProxyError;
|
||||
/// 供 handler/forwarder 外部使用的公开函数。
|
||||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return "openai_responses";
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
@@ -88,8 +95,21 @@ pub fn transform_claude_request_for_api_format(
|
||||
.and_then(|m| m.prompt_cache_key.as_deref());
|
||||
|
||||
match api_format {
|
||||
"openai_responses" => super::transform_responses::anthropic_to_responses(body, cache_key),
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, cache_key),
|
||||
"openai_responses" => {
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||||
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
|
||||
let is_codex_oauth = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
super::transform_responses::anthropic_to_responses(
|
||||
body,
|
||||
Some(cache_key),
|
||||
is_codex_oauth,
|
||||
)
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||||
body,
|
||||
shadow_store,
|
||||
@@ -112,10 +132,12 @@ impl ClaudeAdapter {
|
||||
///
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||||
/// - CodexOAuth: meta.provider_type 为 codex_oauth
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 Gemini Native 格式
|
||||
if self.get_api_format(provider) == "gemini_native" {
|
||||
return match self.extract_key(provider) {
|
||||
Some(key) if key.starts_with("ya29.") || key.starts_with('{') => {
|
||||
@@ -125,6 +147,11 @@ impl ClaudeAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
// 检测 Codex OAuth (ChatGPT Plus/Pro)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
|
||||
// 检测 GitHub Copilot
|
||||
if self.is_github_copilot(provider) {
|
||||
return ProviderType::GitHubCopilot;
|
||||
@@ -143,6 +170,16 @@ impl ClaudeAdapter {
|
||||
ProviderType::Claude
|
||||
}
|
||||
|
||||
/// 检测是否为 Codex OAuth 供应商(ChatGPT Plus/Pro 反代)
|
||||
fn is_codex_oauth(&self, provider: &Provider) -> bool {
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测是否为 GitHub Copilot 供应商
|
||||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||||
// 方式1: 检查 meta.provider_type
|
||||
@@ -274,6 +311,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return Ok("https://chatgpt.com/backend-api/codex".to_string());
|
||||
}
|
||||
|
||||
// 1. 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
@@ -324,6 +366,15 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
));
|
||||
}
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro) 同样使用占位符
|
||||
// 实际的 access_token 由 CodexOAuthManager 动态提供
|
||||
if provider_type == ProviderType::CodexOAuth {
|
||||
return Some(AuthInfo::new(
|
||||
"codex_oauth_placeholder".to_string(),
|
||||
AuthStrategy::CodexOAuth,
|
||||
));
|
||||
}
|
||||
|
||||
let key = self.extract_key(provider)?;
|
||||
|
||||
match provider_type {
|
||||
@@ -344,6 +395,12 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
// Codex OAuth: 所有请求统一走 /responses 端点
|
||||
if base_url == "https://chatgpt.com/backend-api/codex" {
|
||||
let _ = endpoint; // 忽略原始 endpoint
|
||||
return "https://chatgpt.com/backend-api/codex/responses".to_string();
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||
@@ -393,6 +450,20 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
),
|
||||
]
|
||||
}
|
||||
AuthStrategy::CodexOAuth => {
|
||||
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
|
||||
// ChatGPT-Account-Id 由 forwarder 注入额外 header
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static("cc-switch"),
|
||||
),
|
||||
]
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -457,6 +528,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Codex OAuth 总是需要格式转换 (Anthropic → OpenAI Responses API)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ mod adapter;
|
||||
mod auth;
|
||||
mod claude;
|
||||
mod codex;
|
||||
pub mod codex_oauth_auth;
|
||||
pub mod copilot_auth;
|
||||
mod gemini;
|
||||
pub(crate) mod gemini_schema;
|
||||
@@ -62,6 +63,8 @@ pub enum ProviderType {
|
||||
OpenRouter,
|
||||
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
|
||||
GitHubCopilot,
|
||||
/// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换)
|
||||
CodexOAuth,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
@@ -74,6 +77,7 @@ impl ProviderType {
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::GitHubCopilot => true,
|
||||
ProviderType::CodexOAuth => true,
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
@@ -90,6 +94,7 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +118,9 @@ impl ProviderType {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 base_url 是否为 GitHub Copilot
|
||||
@@ -187,6 +195,7 @@ impl ProviderType {
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
ProviderType::GitHubCopilot => "github_copilot",
|
||||
ProviderType::CodexOAuth => "codex_oauth",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +220,7 @@ impl std::str::FromStr for ProviderType {
|
||||
"github_copilot" | "github-copilot" | "githubcopilot" => {
|
||||
Ok(ProviderType::GitHubCopilot)
|
||||
}
|
||||
"codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth),
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -240,7 +250,8 @@ pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn Pr
|
||||
ProviderType::Claude
|
||||
| ProviderType::ClaudeAuth
|
||||
| ProviderType::OpenRouter
|
||||
| ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),
|
||||
| ProviderType::GitHubCopilot
|
||||
| ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()),
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut next_content_index: u32 = 0;
|
||||
@@ -107,8 +108,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
while let Some(line) = take_sse_block(&mut buffer) {
|
||||
if line.trim().is_empty() {
|
||||
@@ -747,4 +747,45 @@ mod tests {
|
||||
assert!(deltas.contains(&"{\"a\":"));
|
||||
assert!(deltas.contains(&"1}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_chinese_split_across_chunks_no_replacement_chars() {
|
||||
// "你好" split across two TCP chunks inside a streaming text delta.
|
||||
// Before the fix, from_utf8_lossy would produce U+FFFD for each half.
|
||||
let full = concat!(
|
||||
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
let bytes = full.as_bytes();
|
||||
|
||||
// Find "你" in the byte stream and split inside it
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 1; // split after first byte of "你"
|
||||
|
||||
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
|
||||
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
|
||||
|
||||
let upstream = stream::iter(vec![
|
||||
Ok::<_, std::io::Error>(chunk1),
|
||||
Ok::<_, std::io::Error>(chunk2),
|
||||
]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
// Must contain the original Chinese characters, not replacement chars
|
||||
assert!(
|
||||
merged.contains("你好"),
|
||||
"expected '你好' in output, got replacement chars (U+FFFD)"
|
||||
);
|
||||
assert!(
|
||||
!merged.contains('\u{FFFD}'),
|
||||
"output must not contain U+FFFD replacement characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut current_model: Option<String> = None;
|
||||
let mut has_sent_message_start = false;
|
||||
@@ -118,8 +119,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// SSE 事件由 \n\n 分隔
|
||||
while let Some(block) = take_sse_block(&mut buffer) {
|
||||
@@ -1026,4 +1026,45 @@ mod tests {
|
||||
assert_eq!(text_stops, 1);
|
||||
assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_responses_chinese_split_across_chunks_no_replacement_chars() {
|
||||
// Chinese text delta split across two TCP chunks.
|
||||
let full = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cn\",\"model\":\"gpt-4o\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好世界\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":4}}}\n\n"
|
||||
);
|
||||
let bytes = full.as_bytes();
|
||||
|
||||
// Find "你" and split inside it
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 2; // split after second byte of "你"
|
||||
|
||||
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
|
||||
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
|
||||
|
||||
let upstream = stream::iter(vec![
|
||||
Ok::<_, std::io::Error>(chunk1),
|
||||
Ok::<_, std::io::Error>(chunk2),
|
||||
]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(
|
||||
merged.contains("你好世界"),
|
||||
"expected '你好世界' in output, got replacement chars (U+FFFD)"
|
||||
);
|
||||
assert!(
|
||||
!merged.contains('\u{FFFD}'),
|
||||
"output must not contain U+FFFD replacement characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
}
|
||||
}
|
||||
|
||||
normalize_openai_system_messages(&mut messages);
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数 — o-series 模型需要 max_completion_tokens
|
||||
@@ -182,6 +183,57 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
let system_count = messages
|
||||
.iter()
|
||||
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system"))
|
||||
.count();
|
||||
|
||||
if system_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if system_count == 1 {
|
||||
if let Some(index) = messages.iter().position(|message| {
|
||||
message.get("role").and_then(|value| value.as_str()) == Some("system")
|
||||
}) {
|
||||
if index > 0 {
|
||||
let message = messages.remove(index);
|
||||
messages.insert(0, message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
messages.retain(|message| {
|
||||
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match message.get("content") {
|
||||
Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()),
|
||||
Some(Value::Array(content_parts)) => {
|
||||
let text = content_parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !text.is_empty() {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
fn convert_message_to_openai(
|
||||
role: &str,
|
||||
@@ -560,6 +612,31 @@ mod tests {
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code."},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "system", "content": "Follow repo conventions."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise.\nFollow repo conventions."
|
||||
);
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let input = json!({
|
||||
|
||||
@@ -14,7 +14,14 @@ use serde_json::{json, Value};
|
||||
/// Anthropic 请求 → OpenAI Responses 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
|
||||
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
|
||||
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
|
||||
pub fn anthropic_to_responses(
|
||||
body: Value,
|
||||
cache_key: Option<&str>,
|
||||
is_codex_oauth: bool,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -103,6 +110,59 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 特殊协议约束:
|
||||
// 整体依据:OpenAI 官方 codex-rs 的 `ResponsesApiRequest` 结构体
|
||||
// (codex-rs/codex-api/src/common.rs) 是 ChatGPT 反代后端的协议契约。
|
||||
// 任何不在该结构体里的字段都可能被 ChatGPT 后端以
|
||||
// "Unsupported parameter: ..." 400 拒绝;任何在结构体里的必填字段
|
||||
// 都需要在请求体里出现。
|
||||
//
|
||||
// 字段处理:
|
||||
// - store: 必须显式为 false(ChatGPT 消费级后端不允许服务端持久化)
|
||||
// - include: 必须包含 "reasoning.encrypted_content",
|
||||
// 否则多轮 reasoning 中间态会丢失(无服务端状态 + 无加密回传 = 上下文断链)
|
||||
// - max_output_tokens / temperature / top_p: 必须删除
|
||||
// (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们)
|
||||
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
|
||||
// (cc-switch 的 transform 当前是"条件写入",可能产生缺失)
|
||||
// - stream: 必须永远 true(codex-rs 硬编码 true,且 cc-switch 的
|
||||
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false)
|
||||
if is_codex_oauth {
|
||||
result["store"] = json!(false);
|
||||
|
||||
const REASONING_MARKER: &str = "reasoning.encrypted_content";
|
||||
let mut includes: Vec<Value> = body
|
||||
.get("include")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(REASONING_MARKER))
|
||||
{
|
||||
includes.push(json!(REASONING_MARKER));
|
||||
}
|
||||
result["include"] = json!(includes);
|
||||
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
// —— 删除 ChatGPT 反代不接受的字段 ——
|
||||
obj.remove("max_output_tokens");
|
||||
obj.remove("temperature");
|
||||
obj.remove("top_p");
|
||||
|
||||
// —— 兜底必填字段(or_insert:客户端送了什么就保留,否则注入默认值)——
|
||||
obj.entry("instructions".to_string()).or_insert(json!(""));
|
||||
obj.entry("tools".to_string()).or_insert(json!([]));
|
||||
obj.entry("parallel_tool_calls".to_string())
|
||||
.or_insert(json!(false));
|
||||
|
||||
// —— 强制覆盖 stream = true ——
|
||||
// 即便客户端误传 stream:false 也要覆盖,因为 codex-rs 永远 true,
|
||||
// 且 cc-switch SSE 解析层只支持流式响应。
|
||||
obj.insert("stream".to_string(), json!(true));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -468,7 +528,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
assert_eq!(result["max_output_tokens"], 1024);
|
||||
assert_eq!(result["input"][0]["role"], "user");
|
||||
@@ -487,7 +547,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
// system should not appear in input
|
||||
assert_eq!(result["input"].as_array().unwrap().len(), 1);
|
||||
@@ -505,7 +565,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
|
||||
}
|
||||
|
||||
@@ -522,7 +582,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["name"], "get_weather");
|
||||
assert!(result["tools"][0].get("parameters").is_some());
|
||||
@@ -539,7 +599,7 @@ mod tests {
|
||||
"tool_choice": {"type": "any"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tool_choice"], "required");
|
||||
}
|
||||
|
||||
@@ -552,7 +612,7 @@ mod tests {
|
||||
"tool_choice": {"type": "tool", "name": "get_weather"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["name"], "get_weather");
|
||||
}
|
||||
@@ -571,7 +631,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: assistant message (text) + function_call item
|
||||
@@ -601,7 +661,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: function_call_output item (lifted)
|
||||
@@ -625,7 +685,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// thinking should be discarded, only text remains
|
||||
@@ -648,7 +708,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let content = result["input"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
@@ -806,7 +866,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["model"], "o3-mini");
|
||||
}
|
||||
|
||||
@@ -818,7 +878,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id")).unwrap();
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "my-provider-id");
|
||||
}
|
||||
|
||||
@@ -836,7 +896,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result["tools"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
@@ -853,7 +913,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result["input"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
@@ -915,7 +975,7 @@ mod tests {
|
||||
"max_tokens": 4096,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["max_output_tokens"], 4096);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
@@ -929,7 +989,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -943,7 +1003,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -956,7 +1016,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -969,7 +1029,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "medium");
|
||||
}
|
||||
|
||||
@@ -982,7 +1042,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
@@ -995,7 +1055,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
@@ -1008,7 +1068,271 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert!(result.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
// ==================== Codex OAuth (ChatGPT 反代) 协议约束 ====================
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_sets_store_and_include() {
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
// store 必须显式为 false(ChatGPT 后端拒绝 true)
|
||||
assert_eq!(result["store"], json!(false));
|
||||
|
||||
// include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning)
|
||||
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_non_codex_omits_store_and_include() {
|
||||
// 回归护栏:is_codex_oauth=false 时,行为必须与今日字节级一致
|
||||
// —— 不写 store、不写 include,OpenRouter / Azure / OpenAI 付费 API 路径不受影响
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert!(result.get("store").is_none());
|
||||
assert!(result.get("include").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_preserves_existing_include() {
|
||||
// 客户端预置了 include:union 保留原有项 + 添加 marker,不重复
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"include": ["something.else", "reasoning.encrypted_content"]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let includes = result["include"]
|
||||
.as_array()
|
||||
.expect("include should be array");
|
||||
|
||||
// 原有项必须保留
|
||||
assert!(includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some("something.else")));
|
||||
// marker 必须存在
|
||||
assert!(includes
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some("reasoning.encrypted_content")));
|
||||
// 不重复:marker 只出现一次
|
||||
let marker_count = includes
|
||||
.iter()
|
||||
.filter(|v| v.as_str() == Some("reasoning.encrypted_content"))
|
||||
.count();
|
||||
assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
|
||||
// ChatGPT Plus/Pro 反代不接受 max_output_tokens(OpenAI 官方 codex-rs 的
|
||||
// ResponsesApiRequest 结构体里也没有这个字段),必须删除,否则服务端 400:
|
||||
// "Unsupported parameter: max_output_tokens"
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("max_output_tokens").is_none(),
|
||||
"Codex OAuth 路径必须删除 max_output_tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_non_codex_keeps_max_output_tokens() {
|
||||
// 回归护栏:非 Codex OAuth 路径必须保留 max_output_tokens
|
||||
// —— OpenAI 付费 Responses API / Azure 等仍然依赖这个字段
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert_eq!(result["max_output_tokens"], json!(1024));
|
||||
}
|
||||
|
||||
// ==================== 第二轮:P0 + P1 字段对齐 ====================
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_strips_temperature() {
|
||||
// P0: ChatGPT 反代不接受 temperature
|
||||
// 依据:OpenAI 官方 codex-rs 的 ResponsesApiRequest 结构体根本没有这个字段
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("temperature").is_none(),
|
||||
"Codex OAuth 路径必须删除 temperature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_strips_top_p() {
|
||||
// P0: ChatGPT 反代不接受 top_p
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"top_p": 0.9,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("top_p").is_none(),
|
||||
"Codex OAuth 路径必须删除 top_p"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_defaults_required_fields_when_absent() {
|
||||
// P1: 极简输入(无 system / 无 tools / 无 stream),断言四个必填字段都被注入默认值
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
json!(""),
|
||||
"instructions 缺失时应兜底为空字符串"
|
||||
);
|
||||
assert_eq!(result["tools"], json!([]), "tools 缺失时应兜底为空数组");
|
||||
assert_eq!(
|
||||
result["parallel_tool_calls"],
|
||||
json!(false),
|
||||
"parallel_tool_calls 应兜底为 false"
|
||||
);
|
||||
assert_eq!(result["stream"], json!(true), "stream 应被强制设为 true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_preserves_existing_instructions_and_tools() {
|
||||
// P1: 客户端送了 system 和 tools,应保留原值,不被默认值覆盖
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant",
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}}
|
||||
}
|
||||
}],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
json!("You are a helpful assistant"),
|
||||
"client 已送的 instructions 必须保留"
|
||||
);
|
||||
|
||||
let tools = result["tools"].as_array().expect("tools 应为数组");
|
||||
assert_eq!(tools.len(), 1, "client 已送的 tools 必须保留");
|
||||
assert_eq!(tools[0]["name"], json!("get_weather"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_oauth_forces_stream_true_even_when_client_sends_false() {
|
||||
// 即使客户端误传 stream:false,也要强制覆盖为 true
|
||||
// 依据:cc-switch SSE 解析层只支持流式响应
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"stream": false,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["stream"],
|
||||
json!(true),
|
||||
"Codex OAuth 路径下 stream 必须强制为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_codex_keeps_temperature_and_top_p() {
|
||||
// 回归护栏:非 Codex OAuth 路径必须保留 temperature/top_p
|
||||
// —— 防止 P0 删除逻辑误扩散到 OpenRouter / Azure / 付费 OpenAI 路径
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert_eq!(result["temperature"], json!(0.7));
|
||||
assert_eq!(result["top_p"], json!(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_codex_does_not_inject_default_required_fields() {
|
||||
// 回归护栏:非 Codex OAuth 路径不应被 P1 默认值污染
|
||||
// —— OpenRouter / Azure / 付费 OpenAI 等保持原有"条件写入"语义
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
"非 Codex OAuth 路径不应注入 parallel_tool_calls"
|
||||
);
|
||||
assert!(
|
||||
result.get("stream").is_none(),
|
||||
"非 Codex OAuth 路径不应注入 stream"
|
||||
);
|
||||
// instructions 和 tools 因为客户端没送,所以不应出现
|
||||
assert!(
|
||||
result.get("instructions").is_none(),
|
||||
"非 Codex OAuth 路径下 instructions 在客户端未送时不应被注入"
|
||||
);
|
||||
assert!(
|
||||
result.get("tools").is_none(),
|
||||
"非 Codex OAuth 路径下 tools 在客户端未送时不应被注入"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ impl StreamHandler {
|
||||
async_stream::stream! {
|
||||
let mut _last_activity = Instant::now();
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
@@ -82,8 +83,7 @@ impl StreamHandler {
|
||||
_last_activity = Instant::now();
|
||||
|
||||
// 解析 SSE 事件
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
|
||||
@@ -568,6 +568,7 @@ pub fn create_logged_passthrough_stream(
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut collector = usage_collector;
|
||||
let mut is_first_chunk = true;
|
||||
|
||||
@@ -619,8 +620,7 @@ pub fn create_logged_passthrough_stream(
|
||||
);
|
||||
}
|
||||
is_first_chunk = false;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 尝试解析并记录完整的 SSE 事件
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
|
||||
+274
-1
@@ -22,9 +22,71 @@ pub(crate) fn take_sse_block(buffer: &mut String) -> Option<String> {
|
||||
Some(block)
|
||||
}
|
||||
|
||||
/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte
|
||||
/// characters that are split across chunk boundaries.
|
||||
///
|
||||
/// `remainder` accumulates trailing bytes from the previous chunk that form an
|
||||
/// incomplete UTF-8 sequence (at most 3 bytes under normal operation). On each
|
||||
/// call the remainder is prepended to `new_bytes`, the longest valid UTF-8
|
||||
/// prefix is appended to `buffer`, and any trailing incomplete bytes are saved
|
||||
/// back into `remainder` for the next call.
|
||||
///
|
||||
/// A defensive guard discards `remainder` via lossy conversion if it ever
|
||||
/// exceeds 3 bytes, which cannot happen with well-formed UTF-8 streams.
|
||||
pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec<u8>, new_bytes: &[u8]) {
|
||||
// Build the byte slice to decode: prepend any leftover bytes from previous chunk.
|
||||
let (owned, bytes): (Option<Vec<u8>>, &[u8]) = if remainder.is_empty() {
|
||||
(None, new_bytes)
|
||||
} else {
|
||||
// Defensive guard: remainder should never exceed 3 bytes (max incomplete
|
||||
// UTF-8 sequence is 3 bytes: a 4-byte char missing its last byte). If it
|
||||
// does, the stream is producing genuinely invalid bytes; flush them lossy
|
||||
// and start fresh.
|
||||
if remainder.len() > 3 {
|
||||
buffer.push_str(&String::from_utf8_lossy(remainder));
|
||||
remainder.clear();
|
||||
(None, new_bytes)
|
||||
} else {
|
||||
let mut combined = std::mem::take(remainder);
|
||||
combined.extend_from_slice(new_bytes);
|
||||
(Some(combined), &[])
|
||||
}
|
||||
};
|
||||
let input = owned.as_deref().unwrap_or(bytes);
|
||||
|
||||
// Decode loop: consume all valid UTF-8 and any genuinely invalid bytes,
|
||||
// only leaving a trailing incomplete sequence in remainder.
|
||||
let mut pos = 0;
|
||||
loop {
|
||||
match std::str::from_utf8(&input[pos..]) {
|
||||
Ok(s) => {
|
||||
buffer.push_str(s);
|
||||
// Everything consumed – remainder stays empty.
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let valid_up_to = pos + e.valid_up_to();
|
||||
buffer.push_str(
|
||||
// Safety: from_utf8 guarantees [pos..valid_up_to] is valid UTF-8.
|
||||
std::str::from_utf8(&input[pos..valid_up_to]).unwrap(),
|
||||
);
|
||||
if let Some(invalid_len) = e.error_len() {
|
||||
// Genuinely invalid byte(s) – emit U+FFFD and continue.
|
||||
buffer.push('\u{FFFD}');
|
||||
pos = valid_up_to + invalid_len;
|
||||
} else {
|
||||
// Incomplete trailing sequence – stash for next chunk.
|
||||
*remainder = input[valid_up_to..].to_vec();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{strip_sse_field, take_sse_block};
|
||||
use super::{append_utf8_safe, strip_sse_field, take_sse_block};
|
||||
|
||||
#[test]
|
||||
fn strip_sse_field_accepts_optional_space() {
|
||||
@@ -68,4 +130,215 @@ mod tests {
|
||||
);
|
||||
assert_eq!(buffer, "rest");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// append_utf8_safe tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ascii_passthrough() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hello world");
|
||||
assert_eq!(buf, "hello world");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_multibyte_in_single_chunk() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
append_utf8_safe(&mut buf, &mut rem, "你好世界".as_bytes());
|
||||
assert_eq!(buf, "你好世界");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_multibyte_across_two_chunks() {
|
||||
// "你" = E4 BD A0 (3 bytes)
|
||||
let bytes = "你".as_bytes();
|
||||
assert_eq!(bytes.len(), 3);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Chunk 1: first 2 bytes (incomplete)
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..2]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 2);
|
||||
|
||||
// Chunk 2: last byte completes the character
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[2..]);
|
||||
assert_eq!(buf, "你");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_four_byte_char_across_chunks() {
|
||||
// 😀 = F0 9F 98 80 (4 bytes)
|
||||
let bytes = "😀".as_bytes();
|
||||
assert_eq!(bytes.len(), 4);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Send 1 byte at a time
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..1]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[1..2]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 2);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[2..3]);
|
||||
assert_eq!(buf, "");
|
||||
assert_eq!(rem.len(), 3);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[3..]);
|
||||
assert_eq!(buf, "😀");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_ascii_and_split_multibyte() {
|
||||
// "hi你" = 68 69 E4 BD A0
|
||||
let all = "hi你".as_bytes();
|
||||
assert_eq!(all.len(), 5);
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Chunk 1: "hi" + first byte of "你"
|
||||
append_utf8_safe(&mut buf, &mut rem, &all[..3]);
|
||||
assert_eq!(buf, "hi");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Chunk 2: remaining 2 bytes of "你"
|
||||
append_utf8_safe(&mut buf, &mut rem, &all[3..]);
|
||||
assert_eq!(buf, "hi你");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_split_characters_in_sequence() {
|
||||
let text = "你好";
|
||||
let bytes = text.as_bytes(); // E4 BD A0 E5 A5 BD
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Split in the middle: first char complete + 1 byte of second
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..4]);
|
||||
assert_eq!(buf, "你");
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Remaining 2 bytes complete second char
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[4..]);
|
||||
assert_eq!(buf, "你好");
|
||||
assert!(rem.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_chunks_are_harmless() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"");
|
||||
assert_eq!(buf, "");
|
||||
assert!(rem.is_empty());
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"ok");
|
||||
assert_eq!(buf, "ok");
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"");
|
||||
assert_eq!(buf, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_json_with_chinese_split_at_boundary() {
|
||||
// Simulates an SSE data line with Chinese content split across chunks
|
||||
let json_line = "data: {\"text\":\"你好\"}\n\n";
|
||||
let bytes = json_line.as_bytes();
|
||||
|
||||
// Find where "你" starts in the byte stream and split there
|
||||
let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap();
|
||||
let split_point = ni_start + 1; // split inside "你"
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[..split_point]);
|
||||
append_utf8_safe(&mut buf, &mut rem, &bytes[split_point..]);
|
||||
|
||||
assert_eq!(buf, json_line);
|
||||
assert!(rem.is_empty());
|
||||
|
||||
// Verify the buffer can be parsed as SSE with valid JSON
|
||||
let data = strip_sse_field(buf.lines().next().unwrap(), "data").unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(data).unwrap();
|
||||
assert_eq!(parsed["text"], "你好");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bytes_flushed_immediately_not_accumulated() {
|
||||
// 0xFF is never valid in UTF-8 – it should be replaced immediately,
|
||||
// not stashed in remainder.
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// "hi" + invalid byte + "ok"
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hi\xFFok");
|
||||
assert!(
|
||||
rem.is_empty(),
|
||||
"remainder should be empty after invalid byte"
|
||||
);
|
||||
assert!(buf.contains("hi"), "valid prefix must be present");
|
||||
assert!(buf.contains("ok"), "valid suffix must be present");
|
||||
assert!(buf.contains('\u{FFFD}'), "invalid byte must produce U+FFFD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_byte_in_slow_path_flushed_immediately() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Prime remainder with an incomplete sequence (first byte of "你")
|
||||
append_utf8_safe(&mut buf, &mut rem, &"你".as_bytes()[..1]);
|
||||
assert_eq!(rem.len(), 1);
|
||||
|
||||
// Next chunk starts with an invalid byte – the stale remainder and the
|
||||
// invalid byte should both be flushed, not accumulated.
|
||||
append_utf8_safe(&mut buf, &mut rem, b"\xFFworld");
|
||||
assert!(rem.is_empty(), "remainder should be empty");
|
||||
assert!(
|
||||
buf.contains("world"),
|
||||
"valid data after invalid byte must appear"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defensive_guard_flushes_oversized_remainder() {
|
||||
let mut buf = String::new();
|
||||
let mut rem = Vec::new();
|
||||
|
||||
// Manually inject 4 invalid bytes into remainder to trigger the >3 guard.
|
||||
// This can't happen with well-formed UTF-8, but tests the safety net.
|
||||
rem.extend_from_slice(b"\x80\x80\x80\x80");
|
||||
assert_eq!(rem.len(), 4);
|
||||
|
||||
append_utf8_safe(&mut buf, &mut rem, b"hello");
|
||||
// The 4 invalid bytes should have been flushed lossy, then "hello" decoded.
|
||||
assert!(rem.is_empty(), "remainder must be empty after guard flush");
|
||||
assert!(
|
||||
buf.contains("hello"),
|
||||
"valid data after guard flush must appear"
|
||||
);
|
||||
// The 4 invalid bytes each produce a U+FFFD
|
||||
let replacement_count = buf.chars().filter(|&c| c == '\u{FFFD}').count();
|
||||
assert_eq!(
|
||||
replacement_count, 4,
|
||||
"each invalid byte should produce one U+FFFD"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! 供应商余额查询服务
|
||||
//!
|
||||
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
|
||||
//! 返回 UsageResult 格式,与现有用量系统无缝对接。
|
||||
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use std::time::Duration;
|
||||
|
||||
// ── 供应商检测 ──────────────────────────────────────────────
|
||||
|
||||
enum BalanceProvider {
|
||||
DeepSeek,
|
||||
StepFun,
|
||||
SiliconFlow,
|
||||
SiliconFlowEn,
|
||||
OpenRouter,
|
||||
NovitaAI,
|
||||
}
|
||||
|
||||
fn detect_provider(base_url: &str) -> Option<BalanceProvider> {
|
||||
let url = base_url.to_lowercase();
|
||||
if url.contains("api.deepseek.com") {
|
||||
Some(BalanceProvider::DeepSeek)
|
||||
} else if url.contains("api.stepfun.ai") || url.contains("api.stepfun.com") {
|
||||
Some(BalanceProvider::StepFun)
|
||||
} else if url.contains("api.siliconflow.cn") {
|
||||
Some(BalanceProvider::SiliconFlow)
|
||||
} else if url.contains("api.siliconflow.com") {
|
||||
Some(BalanceProvider::SiliconFlowEn)
|
||||
} else if url.contains("openrouter.ai") {
|
||||
Some(BalanceProvider::OpenRouter)
|
||||
} else if url.contains("api.novita.ai") {
|
||||
Some(BalanceProvider::NovitaAI)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn make_error(msg: String) -> UsageResult {
|
||||
UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(msg),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_auth_error(status: reqwest::StatusCode) -> UsageResult {
|
||||
UsageResult {
|
||||
success: false,
|
||||
data: Some(vec![UsageData {
|
||||
plan_name: None,
|
||||
remaining: None,
|
||||
total: None,
|
||||
used: None,
|
||||
unit: None,
|
||||
is_valid: Some(false),
|
||||
invalid_message: Some(format!("Authentication failed (HTTP {status})")),
|
||||
extra: None,
|
||||
}]),
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeepSeek ────────────────────────────────────────────────
|
||||
// GET https://api.deepseek.com/user/balance
|
||||
// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available }
|
||||
|
||||
async fn query_deepseek(api_key: &str) -> UsageResult {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
.get("https://api.deepseek.com/user/balance")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return make_auth_error(status);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
let is_available = body
|
||||
.get("is_available")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let mut data = Vec::new();
|
||||
|
||||
if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) {
|
||||
for info in infos {
|
||||
let currency = info
|
||||
.get("currency")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("CNY");
|
||||
let total = parse_f64_field(info, "total_balance");
|
||||
|
||||
data.push(UsageData {
|
||||
plan_name: Some(currency.to_string()),
|
||||
remaining: total,
|
||||
total: None,
|
||||
used: None,
|
||||
unit: Some(currency.to_string()),
|
||||
is_valid: Some(is_available),
|
||||
invalid_message: if !is_available {
|
||||
Some("Insufficient balance".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
UsageResult {
|
||||
success: true,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── StepFun ─────────────────────────────────────────────────
|
||||
// GET https://api.stepfun.com/v1/accounts
|
||||
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
|
||||
|
||||
async fn query_stepfun(api_key: &str) -> UsageResult {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
.get("https://api.stepfun.com/v1/accounts")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return make_auth_error(status);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
let balance = parse_f64_field(&body, "balance").unwrap_or(0.0);
|
||||
|
||||
UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![UsageData {
|
||||
plan_name: Some("StepFun".to_string()),
|
||||
remaining: Some(balance),
|
||||
total: None,
|
||||
used: None,
|
||||
unit: Some("CNY".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: None,
|
||||
}]),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── SiliconFlow ─────────────────────────────────────────────
|
||||
// GET https://api.siliconflow.cn/v1/user/info (or .com for EN)
|
||||
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
|
||||
|
||||
async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let domain = if is_cn {
|
||||
"api.siliconflow.cn"
|
||||
} else {
|
||||
"api.siliconflow.com"
|
||||
};
|
||||
let url = format!("https://{domain}/v1/user/info");
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return make_auth_error(status);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => return make_error("Missing 'data' field in response".to_string()),
|
||||
};
|
||||
|
||||
let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0);
|
||||
|
||||
UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![UsageData {
|
||||
plan_name: Some("SiliconFlow".to_string()),
|
||||
remaining: Some(total_balance),
|
||||
total: None,
|
||||
used: None,
|
||||
unit: Some("CNY".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: None,
|
||||
}]),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenRouter ──────────────────────────────────────────────
|
||||
// GET https://openrouter.ai/api/v1/credits
|
||||
// Response: { data: { total_credits, total_usage } }
|
||||
|
||||
async fn query_openrouter(api_key: &str) -> UsageResult {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
.get("https://openrouter.ai/api/v1/credits")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return make_auth_error(status);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
let data = body.get("data").unwrap_or(&body);
|
||||
let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0);
|
||||
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
|
||||
let remaining = total_credits - total_usage;
|
||||
|
||||
UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![UsageData {
|
||||
plan_name: Some("OpenRouter".to_string()),
|
||||
remaining: Some(remaining),
|
||||
total: Some(total_credits),
|
||||
used: Some(total_usage),
|
||||
unit: Some("USD".to_string()),
|
||||
is_valid: Some(remaining > 0.0),
|
||||
invalid_message: if remaining <= 0.0 {
|
||||
Some("No credits remaining".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
extra: None,
|
||||
}]),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Novita AI ───────────────────────────────────────────────
|
||||
// GET https://api.novita.ai/v3/user/balance
|
||||
// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices }
|
||||
// 金额单位:0.0001 USD
|
||||
|
||||
async fn query_novita(api_key: &str) -> UsageResult {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
.get("https://api.novita.ai/v3/user/balance")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return make_auth_error(status);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
// Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD
|
||||
let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0;
|
||||
|
||||
UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![UsageData {
|
||||
plan_name: Some("Novita AI".to_string()),
|
||||
remaining: Some(available),
|
||||
total: None,
|
||||
used: None,
|
||||
unit: Some("USD".to_string()),
|
||||
is_valid: Some(available > 0.0),
|
||||
invalid_message: if available <= 0.0 {
|
||||
Some("No balance remaining".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
extra: None,
|
||||
}]),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具函数 ────────────────────────────────────────────────
|
||||
|
||||
/// 解析 JSON 字段为 f64,兼容数字和字符串格式
|
||||
fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f64> {
|
||||
obj.get(field).and_then(|v| {
|
||||
v.as_f64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
}
|
||||
|
||||
// ── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some("API key is empty".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let provider = match detect_provider(base_url) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some("Unknown balance provider".to_string()),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let result = match provider {
|
||||
BalanceProvider::DeepSeek => query_deepseek(api_key).await,
|
||||
BalanceProvider::StepFun => query_stepfun(api_key).await,
|
||||
BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await,
|
||||
BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await,
|
||||
BalanceProvider::OpenRouter => query_openrouter(api_key).await,
|
||||
BalanceProvider::NovitaAI => query_novita(api_key).await,
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod balance;
|
||||
pub mod coding_plan;
|
||||
pub mod config;
|
||||
pub mod env_checker;
|
||||
@@ -8,6 +9,9 @@ pub mod omo;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod session_usage;
|
||||
pub mod session_usage_codex;
|
||||
pub mod session_usage_gemini;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
|
||||
@@ -999,11 +999,12 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
{
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if !providers.is_empty() {
|
||||
return Ok(false); // 已有供应商,跳过
|
||||
}
|
||||
// 允许 "只有官方 seed 预设" 的情况下继续导入 live:
|
||||
// - 启动编排顺序是先 import 后 seed,新用户启动时 providers 为空,导入照常
|
||||
// - 老用户已有非 seed provider,跳过导入(正确)
|
||||
// - 用户手动点 ProviderEmptyState 的导入按钮时,与官方 seed 共存而不被阻塞
|
||||
if state.db.has_non_official_seed_provider(app_type.as_str())? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let settings_config = match app_type {
|
||||
@@ -1208,11 +1209,11 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
}
|
||||
|
||||
let mut imported = 0;
|
||||
let existing = state.db.get_all_providers("opencode")?;
|
||||
let existing_ids = state.db.get_provider_ids("opencode")?;
|
||||
|
||||
for (id, config) in providers {
|
||||
// Skip if already exists in database
|
||||
if existing.contains_key(&id) {
|
||||
if existing_ids.contains(&id) {
|
||||
log::debug!("OpenCode provider '{id}' already exists in database, skipping");
|
||||
continue;
|
||||
}
|
||||
@@ -1265,7 +1266,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
}
|
||||
|
||||
let mut imported = 0;
|
||||
let existing = state.db.get_all_providers("openclaw")?;
|
||||
let existing_ids = state.db.get_provider_ids("openclaw")?;
|
||||
|
||||
for (id, config) in providers {
|
||||
// Validate: skip entries with empty id or no models
|
||||
@@ -1279,7 +1280,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
}
|
||||
|
||||
// Skip if already exists in database
|
||||
if existing.contains_key(&id) {
|
||||
if existing_ids.contains(&id) {
|
||||
log::debug!("OpenClaw provider '{id}' already exists in database, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
//! Claude Code 会话日志使用追踪
|
||||
//!
|
||||
//! 从 ~/.claude/projects/ 下的 JSONL 会话文件中提取 token 使用数据,
|
||||
//! 实现无代理模式下的使用统计。
|
||||
//!
|
||||
//! ## 数据流
|
||||
//! ```text
|
||||
//! ~/.claude/projects/*/*.jsonl → 增量解析 → 去重 → 费用计算 → proxy_request_logs 表
|
||||
//! ```
|
||||
|
||||
use crate::config::get_claude_config_dir;
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 同步结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionSyncResult {
|
||||
pub imported: u32,
|
||||
pub skipped: u32,
|
||||
pub files_scanned: u32,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 数据来源分布
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataSourceSummary {
|
||||
pub data_source: String,
|
||||
pub request_count: u32,
|
||||
pub total_cost_usd: String,
|
||||
}
|
||||
|
||||
/// 从 JSONL 中解析出的 assistant 消息使用数据
|
||||
#[derive(Debug)]
|
||||
struct ParsedAssistantUsage {
|
||||
message_id: String,
|
||||
model: String,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
cache_read_tokens: u32,
|
||||
cache_creation_tokens: u32,
|
||||
stop_reason: Option<String>,
|
||||
timestamp: Option<String>,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 同步 Claude Code 会话日志到使用统计数据库
|
||||
pub fn sync_claude_session_logs(db: &Database) -> Result<SessionSyncResult, AppError> {
|
||||
let projects_dir = get_claude_config_dir().join("projects");
|
||||
if !projects_dir.exists() {
|
||||
return Ok(SessionSyncResult {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: 0,
|
||||
errors: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let mut result = SessionSyncResult {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: 0,
|
||||
errors: vec![],
|
||||
};
|
||||
|
||||
// 收集所有 .jsonl 文件
|
||||
let jsonl_files = collect_jsonl_files(&projects_dir);
|
||||
|
||||
for file_path in &jsonl_files {
|
||||
result.files_scanned += 1;
|
||||
|
||||
match sync_single_file(db, file_path) {
|
||||
Ok((imported, skipped)) => {
|
||||
result.imported += imported;
|
||||
result.skipped += skipped;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{}: {e}", file_path.display());
|
||||
log::warn!("[SESSION-SYNC] 文件解析失败: {msg}");
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.imported > 0 {
|
||||
log::info!(
|
||||
"[SESSION-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件",
|
||||
result.imported,
|
||||
result.skipped,
|
||||
result.files_scanned
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 收集目录下所有 .jsonl 文件
|
||||
fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
let entries = match fs::read_dir(projects_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return files,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// 每个项目目录下的 .jsonl 文件
|
||||
if let Ok(sub_entries) = fs::read_dir(&path) {
|
||||
for sub_entry in sub_entries.flatten() {
|
||||
let sub_path = sub_entry.path();
|
||||
if sub_path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
|
||||
files.push(sub_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
/// 同步单个 JSONL 文件,返回 (imported, skipped)
|
||||
fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
// 获取文件元数据
|
||||
let metadata = fs::metadata(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
|
||||
let file_modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// 检查同步状态
|
||||
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
|
||||
|
||||
// 文件未变化则跳过
|
||||
if file_modified <= last_modified {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
|
||||
// 从上次偏移位置开始增量解析
|
||||
let file =
|
||||
fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut line_offset: i64 = 0;
|
||||
let mut messages: HashMap<String, ParsedAssistantUsage> = HashMap::new();
|
||||
let mut current_session_id: Option<String> = None;
|
||||
|
||||
for line_result in reader.lines() {
|
||||
line_offset += 1;
|
||||
|
||||
// 跳过已处理的行
|
||||
if line_offset <= last_offset {
|
||||
continue;
|
||||
}
|
||||
|
||||
let line = match line_result {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue, // 容忍不完整的最后一行
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// 提取 session ID (从 system 或首条消息)
|
||||
if current_session_id.is_none() {
|
||||
if let Some(sid) = value.get("sessionId").and_then(|v| v.as_str()) {
|
||||
current_session_id = Some(sid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 只处理 assistant 类型的消息
|
||||
if value.get("type").and_then(|t| t.as_str()) != Some("assistant") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let msg_id = match message.get("id").and_then(|v| v.as_str()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let usage = match message.get("usage") {
|
||||
Some(u) => u,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let parsed = ParsedAssistantUsage {
|
||||
message_id: msg_id.clone(),
|
||||
model: message
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
input_tokens: usage
|
||||
.get("input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
output_tokens: usage
|
||||
.get("output_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
cache_read_tokens: usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
stop_reason: message
|
||||
.get("stop_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
timestamp: value
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
session_id: current_session_id.clone(),
|
||||
};
|
||||
|
||||
// 按 message.id 去重:优先保留有 stop_reason 的条目,否则保留最新的
|
||||
let should_replace = match messages.get(&msg_id) {
|
||||
None => true,
|
||||
Some(existing) => {
|
||||
// 新条目有 stop_reason 而旧条目没有 → 替换
|
||||
if parsed.stop_reason.is_some() && existing.stop_reason.is_none() {
|
||||
true
|
||||
}
|
||||
// 两个都有或都没有 stop_reason → 取 output_tokens 更大的
|
||||
else if parsed.stop_reason.is_some() == existing.stop_reason.is_some() {
|
||||
parsed.output_tokens > existing.output_tokens
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if should_replace {
|
||||
messages.insert(msg_id, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// 写入数据库
|
||||
let mut imported: u32 = 0;
|
||||
let mut skipped: u32 = 0;
|
||||
|
||||
for msg in messages.values() {
|
||||
// 只导入有 stop_reason 的最终条目(完整的 API 调用)
|
||||
if msg.stop_reason.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request_id = format!("session:{}", msg.message_id);
|
||||
|
||||
// 跳过 output_tokens 为 0 的无意义条目
|
||||
if msg.output_tokens == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match insert_session_log_entry(db, &request_id, msg) {
|
||||
Ok(true) => imported += 1,
|
||||
Ok(false) => skipped += 1,
|
||||
Err(e) => {
|
||||
log::warn!("[SESSION-SYNC] 插入失败 ({}): {e}", msg.message_id);
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新同步状态
|
||||
update_sync_state(db, &file_path_str, file_modified, line_offset)?;
|
||||
|
||||
Ok((imported, skipped))
|
||||
}
|
||||
|
||||
/// 获取文件的同步状态
|
||||
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
|
||||
rusqlite::params![file_path],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||
);
|
||||
Ok(result.unwrap_or((0, 0)))
|
||||
}
|
||||
|
||||
/// 更新文件的同步状态
|
||||
fn update_sync_state(
|
||||
db: &Database,
|
||||
file_path: &str,
|
||||
last_modified: i64,
|
||||
last_offset: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_path, last_modified, last_offset, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入单条会话日志到 proxy_request_logs,返回是否成功插入 (true=新插入, false=已存在)
|
||||
fn insert_session_log_entry(
|
||||
db: &Database,
|
||||
request_id: &str,
|
||||
msg: &ParsedAssistantUsage,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 检查是否已存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
|
||||
rusqlite::params![request_id],
|
||||
|row| row.get::<_, i64>(0).map(|c| c > 0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 解析时间戳
|
||||
let created_at = msg
|
||||
.timestamp
|
||||
.as_ref()
|
||||
.and_then(|ts| {
|
||||
// 尝试解析 ISO 8601 时间戳
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
// 计算费用
|
||||
let usage = TokenUsage {
|
||||
input_tokens: msg.input_tokens,
|
||||
output_tokens: msg.output_tokens,
|
||||
cache_read_tokens: msg.cache_read_tokens,
|
||||
cache_creation_tokens: msg.cache_creation_tokens,
|
||||
model: Some(msg.model.clone()),
|
||||
};
|
||||
|
||||
let pricing = find_model_pricing_for_session(&conn, &msg.model);
|
||||
let multiplier = Decimal::from(1);
|
||||
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
|
||||
{
|
||||
Some(p) => {
|
||||
let cost = CostCalculator::calculate(&usage, &p, multiplier);
|
||||
(
|
||||
cost.input_cost.to_string(),
|
||||
cost.output_cost.to_string(),
|
||||
cost.cache_read_cost.to_string(),
|
||||
cost.cache_creation_cost.to_string(),
|
||||
cost.total_cost.to_string(),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at, data_source
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_session", // provider_id: 标记为会话来源
|
||||
"claude", // app_type
|
||||
msg.model,
|
||||
msg.model, // request_model = model
|
||||
msg.input_tokens,
|
||||
msg.output_tokens,
|
||||
msg.cache_read_tokens,
|
||||
msg.cache_creation_tokens,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms: 会话日志无此数据
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code: 有 stop_reason 说明请求成功
|
||||
Option::<String>::None, // error_message
|
||||
msg.session_id,
|
||||
Some("session_log"), // provider_type
|
||||
1i64, // is_streaming: Claude Code 通常使用流式
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"session_log", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入会话日志失败: {e}")))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 从 model_pricing 表查找模型定价(支持模糊匹配)
|
||||
fn find_model_pricing_for_session(
|
||||
conn: &rusqlite::Connection,
|
||||
model_id: &str,
|
||||
) -> Option<ModelPricing> {
|
||||
// 精确匹配
|
||||
if let Ok(Some(pricing)) = try_find_pricing(conn, model_id) {
|
||||
return Some(pricing);
|
||||
}
|
||||
|
||||
// 模糊匹配:去掉日期后缀
|
||||
// 例如 "claude-opus-4-6-20260206" -> "claude-opus-4-6"
|
||||
let parts: Vec<&str> = model_id.rsplitn(2, '-').collect();
|
||||
if parts.len() == 2 {
|
||||
if let Some(suffix) = parts.first() {
|
||||
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
if let Ok(Some(pricing)) = try_find_pricing(conn, parts[1]) {
|
||||
return Some(pricing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 LIKE 匹配
|
||||
let pattern = format!("{model_id}%");
|
||||
let result = conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
|
||||
rusqlite::params![pattern],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((input, output, cache_read, cache_creation)) => {
|
||||
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok()
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_find_pricing(
|
||||
conn: &rusqlite::Connection,
|
||||
model_id: &str,
|
||||
) -> Result<Option<ModelPricing>, AppError> {
|
||||
let result = conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((input, output, cache_read, cache_creation)) => {
|
||||
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)
|
||||
.map(Some)
|
||||
.map_err(|e| AppError::Database(format!("解析定价失败: {e}")))
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(format!("查询定价失败: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询数据来源分布统计
|
||||
pub fn get_data_source_breakdown(db: &Database) -> Result<Vec<DataSourceSummary>, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(data_source, 'proxy') as ds, COUNT(*) as cnt,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as cost
|
||||
FROM proxy_request_logs
|
||||
GROUP BY ds
|
||||
ORDER BY cnt DESC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(DataSourceSummary {
|
||||
data_source: row.get(0)?,
|
||||
request_count: row.get::<_, i64>(1)? as u32,
|
||||
total_cost_usd: format!("{:.6}", row.get::<_, f64>(2)?),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut summaries = Vec::new();
|
||||
for row in rows {
|
||||
summaries.push(row.map_err(|e| AppError::Database(e.to_string()))?);
|
||||
}
|
||||
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_from_jsonl_line() {
|
||||
let line = r#"{"type":"assistant","message":{"id":"msg_test123","model":"claude-opus-4-6","usage":{"input_tokens":3,"output_tokens":150,"cache_read_input_tokens":5000,"cache_creation_input_tokens":10000},"stop_reason":"end_turn"},"timestamp":"2026-04-05T12:00:00Z","sessionId":"session-abc"}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
assert_eq!(
|
||||
value.get("type").and_then(|t| t.as_str()),
|
||||
Some("assistant")
|
||||
);
|
||||
|
||||
let message = value.get("message").unwrap();
|
||||
let usage = message.get("usage").unwrap();
|
||||
|
||||
assert_eq!(usage.get("input_tokens").unwrap().as_u64().unwrap(), 3);
|
||||
assert_eq!(usage.get("output_tokens").unwrap().as_u64().unwrap(), 150);
|
||||
assert_eq!(
|
||||
usage
|
||||
.get("cache_read_input_tokens")
|
||||
.unwrap()
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
5000
|
||||
);
|
||||
assert_eq!(
|
||||
usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.unwrap()
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
10000
|
||||
);
|
||||
assert_eq!(
|
||||
message.get("stop_reason").unwrap().as_str().unwrap(),
|
||||
"end_turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_by_message_id() {
|
||||
// 同一个 message.id 有多条,应该取 stop_reason 有值的那条
|
||||
let mut messages: HashMap<String, ParsedAssistantUsage> = HashMap::new();
|
||||
|
||||
// 中间条目(无 stop_reason)
|
||||
let intermediate = ParsedAssistantUsage {
|
||||
message_id: "msg_1".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
input_tokens: 3,
|
||||
output_tokens: 26,
|
||||
cache_read_tokens: 5000,
|
||||
cache_creation_tokens: 10000,
|
||||
stop_reason: None,
|
||||
timestamp: Some("2026-04-05T12:00:00Z".to_string()),
|
||||
session_id: None,
|
||||
};
|
||||
messages.insert("msg_1".to_string(), intermediate);
|
||||
|
||||
// 最终条目(有 stop_reason)
|
||||
let final_entry = ParsedAssistantUsage {
|
||||
message_id: "msg_1".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
input_tokens: 3,
|
||||
output_tokens: 1349,
|
||||
cache_read_tokens: 5000,
|
||||
cache_creation_tokens: 10000,
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
timestamp: Some("2026-04-05T12:00:00Z".to_string()),
|
||||
session_id: None,
|
||||
};
|
||||
|
||||
// 应该替换
|
||||
let should_replace = final_entry.stop_reason.is_some()
|
||||
&& messages.get("msg_1").unwrap().stop_reason.is_none();
|
||||
assert!(should_replace);
|
||||
|
||||
messages.insert("msg_1".to_string(), final_entry);
|
||||
assert_eq!(messages.get("msg_1").unwrap().output_tokens, 1349);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
//! Codex 会话日志使用追踪
|
||||
//!
|
||||
//! 从 ~/.codex/sessions/ 下的 JSONL 会话文件中提取精确 token 使用数据,
|
||||
//! 替代原有的 state_5.sqlite 估算方案。
|
||||
//!
|
||||
//! ## 数据流
|
||||
//! ```text
|
||||
//! ~/.codex/sessions/YYYY/MM/DD/*.jsonl → 增量解析 → delta 计算 → 费用计算 → proxy_request_logs 表
|
||||
//! ```
|
||||
//!
|
||||
//! ## 解析的事件类型
|
||||
//! - `session_meta` → 提取 session_id
|
||||
//! - `turn_context` → 提取当前 model
|
||||
//! - `event_msg` (type=token_count) → 提取累计 token 用量,计算 delta
|
||||
|
||||
use crate::codex_config::get_codex_config_dir;
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use crate::services::session_usage::SessionSyncResult;
|
||||
use rust_decimal::Decimal;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 累计 token 用量(跟踪 total_token_usage 字段)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct CumulativeTokens {
|
||||
input: u64,
|
||||
cached_input: u64,
|
||||
output: u64,
|
||||
}
|
||||
|
||||
/// 单次 API 调用的 token 增量
|
||||
#[derive(Debug)]
|
||||
struct DeltaTokens {
|
||||
input: u32,
|
||||
cached_input: u32,
|
||||
output: u32,
|
||||
}
|
||||
|
||||
impl DeltaTokens {
|
||||
fn is_zero(&self) -> bool {
|
||||
self.input == 0 && self.cached_input == 0 && self.output == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 单文件解析时的运行状态
|
||||
struct FileParseState {
|
||||
session_id: Option<String>,
|
||||
current_model: String,
|
||||
prev_total: Option<CumulativeTokens>,
|
||||
event_index: u32,
|
||||
}
|
||||
|
||||
/// 归一化 Codex 模型名
|
||||
///
|
||||
/// 处理规则(按顺序):
|
||||
/// 1. 转小写:`GLM-4.6` → `glm-4.6`
|
||||
/// 2. 剥离 provider 前缀:`openai/gpt-5.4` → `gpt-5.4`
|
||||
/// 3. 剥离 ISO 日期后缀:`gpt-5.4-2026-03-05` → `gpt-5.4`
|
||||
/// 4. 剥离紧凑日期后缀:`gpt-5.4-20260305` → `gpt-5.4`
|
||||
fn normalize_codex_model(raw: &str) -> String {
|
||||
// Step 1: 小写
|
||||
let mut name = raw.to_lowercase();
|
||||
|
||||
// Step 2: 剥离 "provider/" 前缀(如 openai/, azure/)
|
||||
if let Some(pos) = name.rfind('/') {
|
||||
name = name[pos + 1..].to_string();
|
||||
}
|
||||
|
||||
// Step 3: 剥离 ISO 日期后缀 -YYYY-MM-DD(正好 11 字符)
|
||||
if name.len() > 11 {
|
||||
let suffix = &name[name.len() - 11..];
|
||||
if suffix.as_bytes()[0] == b'-'
|
||||
&& suffix[1..5].chars().all(|c| c.is_ascii_digit())
|
||||
&& suffix.as_bytes()[5] == b'-'
|
||||
&& suffix[6..8].chars().all(|c| c.is_ascii_digit())
|
||||
&& suffix.as_bytes()[8] == b'-'
|
||||
&& suffix[9..11].chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
name.truncate(name.len() - 11);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: 剥离紧凑日期后缀 -YYYYMMDD(正好 9 字符)
|
||||
if name.len() > 9 {
|
||||
let parts: Vec<&str> = name.rsplitn(2, '-').collect();
|
||||
if parts.len() == 2 {
|
||||
if let Some(suffix) = parts.first() {
|
||||
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
name = parts[1].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name
|
||||
}
|
||||
|
||||
/// 计算两次累计值之间的 delta
|
||||
fn compute_delta(prev: &Option<CumulativeTokens>, current: &CumulativeTokens) -> DeltaTokens {
|
||||
match prev {
|
||||
None => DeltaTokens {
|
||||
input: current.input as u32,
|
||||
cached_input: current.cached_input as u32,
|
||||
output: current.output as u32,
|
||||
},
|
||||
Some(p) => DeltaTokens {
|
||||
input: current.input.saturating_sub(p.input) as u32,
|
||||
cached_input: current.cached_input.saturating_sub(p.cached_input) as u32,
|
||||
output: current.output.saturating_sub(p.output) as u32,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 JSON Value 中提取累计 token 用量
|
||||
fn parse_cumulative_tokens(total_usage: &serde_json::Value) -> Option<CumulativeTokens> {
|
||||
if total_usage.is_null() || !total_usage.is_object() {
|
||||
return None;
|
||||
}
|
||||
Some(CumulativeTokens {
|
||||
input: total_usage
|
||||
.get("input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
cached_input: total_usage
|
||||
.get("cached_input_tokens")
|
||||
.or_else(|| total_usage.get("cache_read_input_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
output: total_usage
|
||||
.get("output_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// 同步 Codex 使用数据(从 JSONL 会话日志)
|
||||
pub fn sync_codex_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
|
||||
let codex_dir = get_codex_config_dir();
|
||||
|
||||
let files = collect_codex_session_files(&codex_dir);
|
||||
|
||||
let mut result = SessionSyncResult {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: files.len() as u32,
|
||||
errors: vec![],
|
||||
};
|
||||
|
||||
if files.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
for file_path in &files {
|
||||
match sync_single_codex_file(db, file_path) {
|
||||
Ok((imported, skipped)) => {
|
||||
result.imported += imported;
|
||||
result.skipped += skipped;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Codex 会话文件解析失败 {}: {e}", file_path.display());
|
||||
log::warn!("[CODEX-SYNC] {msg}");
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.imported > 0 {
|
||||
log::info!(
|
||||
"[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, ��描 {} 个文件",
|
||||
result.imported,
|
||||
result.skipped,
|
||||
result.files_scanned
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 收集所有 Codex 会话 JSONL 文件
|
||||
fn collect_codex_session_files(codex_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
// 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录��
|
||||
let sessions_dir = codex_dir.join("sessions");
|
||||
if sessions_dir.is_dir() {
|
||||
collect_jsonl_recursive(&sessions_dir, &mut files, 0, 3);
|
||||
}
|
||||
|
||||
// 2. 扫描 archived_sessions/*.jsonl(扁平归档目录)
|
||||
let archived_dir = codex_dir.join("archived_sessions");
|
||||
if archived_dir.is_dir() {
|
||||
if let Ok(entries) = fs::read_dir(&archived_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
/// 递归扫描目录下的 .jsonl 文件(限制最大深度)
|
||||
fn collect_jsonl_recursive(dir: &Path, files: &mut Vec<PathBuf>, depth: u32, max_depth: u32) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() && depth < max_depth {
|
||||
collect_jsonl_recursive(&path, files, depth + 1, max_depth);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步单�� Codex JSONL 文件,返回 (imported, skipped)
|
||||
fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
// 获取文件元数据
|
||||
let metadata = fs::metadata(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文���元数据: {e}")))?;
|
||||
let file_modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// 检查同步状态
|
||||
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
|
||||
|
||||
// 文件未变化则跳过
|
||||
if file_modified <= last_modified {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
|
||||
// 打开文件逐行解析
|
||||
let file =
|
||||
fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut state = FileParseState {
|
||||
session_id: None,
|
||||
current_model: "unknown".to_string(),
|
||||
prev_total: None,
|
||||
event_index: 0,
|
||||
};
|
||||
|
||||
let mut line_offset: i64 = 0;
|
||||
let mut imported: u32 = 0;
|
||||
let mut skipped: u32 = 0;
|
||||
|
||||
for line_result in reader.lines() {
|
||||
line_offset += 1;
|
||||
|
||||
let line = match line_result {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue, // 容忍不完整的最后一行
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 快速过滤:在 JSON 反序列化前跳过无关行
|
||||
let is_event_msg = line.contains("\"event_msg\"");
|
||||
let is_turn_context = line.contains("\"turn_context\"");
|
||||
let is_session_meta = line.contains("\"session_meta\"");
|
||||
|
||||
if !is_event_msg && !is_turn_context && !is_session_meta {
|
||||
continue;
|
||||
}
|
||||
if is_event_msg && !line.contains("\"token_count\"") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type = match value.get("type").and_then(|t| t.as_str()) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match event_type {
|
||||
"session_meta" => {
|
||||
if state.session_id.is_none() {
|
||||
let payload = value.get("payload");
|
||||
state.session_id = payload
|
||||
.and_then(|p| {
|
||||
p.get("session_id")
|
||||
.or_else(|| p.get("sessionId"))
|
||||
.or_else(|| p.get("id"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
"turn_context" => {
|
||||
if let Some(payload) = value.get("payload") {
|
||||
// model 可能在 payload.model 或 payload.info.model
|
||||
if let Some(model) = payload
|
||||
.get("model")
|
||||
.or_else(|| payload.get("info").and_then(|info| info.get("model")))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
state.current_model = normalize_codex_model(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
"event_msg" => {
|
||||
let payload = match value.get("payload") {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// 只处理 token_count 类型
|
||||
if payload.get("type").and_then(|t| t.as_str()) != Some("token_count") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let info = match payload.get("info") {
|
||||
Some(i) if !i.is_null() => i,
|
||||
_ => continue, // info 为 null 的首个事件跳��
|
||||
};
|
||||
|
||||
// 提取模型(token_count 事件也可能携带 model)
|
||||
if let Some(model) = info
|
||||
.get("model")
|
||||
.or_else(|| info.get("model_name"))
|
||||
.or_else(|| payload.get("model"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
state.current_model = normalize_codex_model(model);
|
||||
}
|
||||
|
||||
// 优先用 total_token_usage(累计值),fallback 到 last_token_usage(增量值)
|
||||
let (cumulative, is_total) = if let Some(total) = info.get("total_token_usage") {
|
||||
(parse_cumulative_tokens(total), true)
|
||||
} else if let Some(last) = info.get("last_token_usage") {
|
||||
(parse_cumulative_tokens(last), false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let cumulative = match cumulative {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let delta = if is_total {
|
||||
// 累计值模式:计算与上次的 delta
|
||||
let d = compute_delta(&state.prev_total, &cumulative);
|
||||
state.prev_total = Some(cumulative);
|
||||
d
|
||||
} else {
|
||||
// 增量值模式:直接使用 last_token_usage 的值
|
||||
DeltaTokens {
|
||||
input: cumulative.input as u32,
|
||||
cached_input: cumulative.cached_input as u32,
|
||||
output: cumulative.output as u32,
|
||||
}
|
||||
};
|
||||
|
||||
// 钳制:cached 不应超过 input(防护异常数据)
|
||||
let delta = DeltaTokens {
|
||||
cached_input: delta.cached_input.min(delta.input),
|
||||
..delta
|
||||
};
|
||||
|
||||
if delta.is_zero() {
|
||||
continue; // 跳过 task 边界的零 delta 事件
|
||||
}
|
||||
|
||||
state.event_index += 1;
|
||||
|
||||
// 跳过已处理的行(但仍需解析以恢复状态)
|
||||
if line_offset <= last_offset {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成唯一 request_id
|
||||
let session_id_str = state.session_id.as_deref().unwrap_or("unknown");
|
||||
let request_id = format!("codex_session:{}:{}", session_id_str, state.event_index);
|
||||
|
||||
// 提取时间戳
|
||||
let timestamp = value
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match insert_codex_session_entry(
|
||||
db,
|
||||
&request_id,
|
||||
&delta,
|
||||
&state.current_model,
|
||||
state.session_id.as_deref(),
|
||||
timestamp.as_deref(),
|
||||
) {
|
||||
Ok(true) => imported += 1,
|
||||
Ok(false) => skipped += 1,
|
||||
Err(e) => {
|
||||
log::warn!("[CODEX-SYNC] 插入失败 ({}): {e}", request_id);
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新同步状态
|
||||
update_sync_state(db, &file_path_str, file_modified, line_offset)?;
|
||||
|
||||
Ok((imported, skipped))
|
||||
}
|
||||
|
||||
/// 插入单条 Codex 会话记录到 proxy_request_logs
|
||||
fn insert_codex_session_entry(
|
||||
db: &Database,
|
||||
request_id: &str,
|
||||
delta: &DeltaTokens,
|
||||
model: &str,
|
||||
session_id: Option<&str>,
|
||||
timestamp: Option<&str>,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 检查是否已存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
|
||||
rusqlite::params![request_id],
|
||||
|row| row.get::<_, i64>(0).map(|c| c > 0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 解析时间戳
|
||||
let created_at = timestamp
|
||||
.and_then(|ts| {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
// 计算费用
|
||||
let usage = TokenUsage {
|
||||
input_tokens: delta.input,
|
||||
output_tokens: delta.output,
|
||||
cache_read_tokens: delta.cached_input,
|
||||
cache_creation_tokens: 0,
|
||||
model: Some(model.to_string()),
|
||||
};
|
||||
|
||||
let pricing = find_codex_pricing(&conn, model);
|
||||
let multiplier = Decimal::from(1);
|
||||
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
|
||||
{
|
||||
Some(p) => {
|
||||
let cost = CostCalculator::calculate(&usage, &p, multiplier);
|
||||
(
|
||||
cost.input_cost.to_string(),
|
||||
cost.output_cost.to_string(),
|
||||
cost.cache_read_cost.to_string(),
|
||||
cost.cache_creation_cost.to_string(),
|
||||
cost.total_cost.to_string(),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at, data_source
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_codex_session", // provider_id
|
||||
"codex", // app_type
|
||||
model,
|
||||
model, // request_model = model
|
||||
delta.input,
|
||||
delta.output,
|
||||
delta.cached_input,
|
||||
0i64, // cache_creation_tokens: Codex 日志无此数据
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code
|
||||
Option::<String>::None, // error_message
|
||||
session_id.map(|s| s.to_string()),
|
||||
Some("codex_session"), // provider_type
|
||||
1i64, // is_streaming
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"codex_session", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取文件的同步状态
|
||||
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
|
||||
rusqlite::params![file_path],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||
);
|
||||
Ok(result.unwrap_or((0, 0)))
|
||||
}
|
||||
|
||||
/// 更新文件的同步状态
|
||||
fn update_sync_state(
|
||||
db: &Database,
|
||||
file_path: &str,
|
||||
last_modified: i64,
|
||||
last_offset: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_path, last_modified, last_offset, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ��找 Codex 模型定价(带归一化)
|
||||
fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
let normalized = normalize_codex_model(model_id);
|
||||
|
||||
// 1. 精确匹配(归一化后的名称)
|
||||
if let Some(pricing) = try_find_pricing(conn, &normalized) {
|
||||
return Some(pricing);
|
||||
}
|
||||
|
||||
// 2. LIKE 模糊匹配(兜底)
|
||||
let pattern = format!("{normalized}%");
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
|
||||
rusqlite::params![pattern],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
}
|
||||
|
||||
/// 精确匹配定价查询
|
||||
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_delta_first_event() {
|
||||
let prev = None;
|
||||
let current = CumulativeTokens {
|
||||
input: 17934,
|
||||
cached_input: 9600,
|
||||
output: 454,
|
||||
};
|
||||
let delta = compute_delta(&prev, ¤t);
|
||||
assert_eq!(delta.input, 17934);
|
||||
assert_eq!(delta.cached_input, 9600);
|
||||
assert_eq!(delta.output, 454);
|
||||
assert!(!delta.is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delta_subsequent_event() {
|
||||
let prev = Some(CumulativeTokens {
|
||||
input: 17934,
|
||||
cached_input: 9600,
|
||||
output: 454,
|
||||
});
|
||||
let current = CumulativeTokens {
|
||||
input: 36722,
|
||||
cached_input: 27904,
|
||||
output: 804,
|
||||
};
|
||||
let delta = compute_delta(&prev, ¤t);
|
||||
assert_eq!(delta.input, 36722 - 17934);
|
||||
assert_eq!(delta.cached_input, 27904 - 9600);
|
||||
assert_eq!(delta.output, 804 - 454);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delta_zero_at_task_boundary() {
|
||||
let prev = Some(CumulativeTokens {
|
||||
input: 58346,
|
||||
cached_input: 46976,
|
||||
output: 1045,
|
||||
});
|
||||
// task 边界:相同的累计值
|
||||
let current = CumulativeTokens {
|
||||
input: 58346,
|
||||
cached_input: 46976,
|
||||
output: 1045,
|
||||
};
|
||||
let delta = compute_delta(&prev, ¤t);
|
||||
assert!(delta.is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delta_saturating_sub() {
|
||||
// 异常情况:当前值小于前值(不应发生,但需防护)
|
||||
let prev = Some(CumulativeTokens {
|
||||
input: 100,
|
||||
cached_input: 50,
|
||||
output: 30,
|
||||
});
|
||||
let current = CumulativeTokens {
|
||||
input: 80,
|
||||
cached_input: 40,
|
||||
output: 20,
|
||||
};
|
||||
let delta = compute_delta(&prev, ¤t);
|
||||
assert_eq!(delta.input, 0);
|
||||
assert_eq!(delta.cached_input, 0);
|
||||
assert_eq!(delta.output, 0);
|
||||
assert!(delta.is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cumulative_tokens_valid() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"input_tokens": 17934,
|
||||
"cached_input_tokens": 9600,
|
||||
"output_tokens": 454,
|
||||
"reasoning_output_tokens": 233,
|
||||
"total_tokens": 18388
|
||||
});
|
||||
let tokens = parse_cumulative_tokens(&json).unwrap();
|
||||
assert_eq!(tokens.input, 17934);
|
||||
assert_eq!(tokens.cached_input, 9600);
|
||||
assert_eq!(tokens.output, 454);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cumulative_tokens_null() {
|
||||
let json = serde_json::Value::Null;
|
||||
assert!(parse_cumulative_tokens(&json).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cumulative_tokens_alt_field_names() {
|
||||
// 某些版本可能使用 cache_read_input_tokens 而非 cached_input_tokens
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"input_tokens": 1000,
|
||||
"cache_read_input_tokens": 500,
|
||||
"output_tokens": 200
|
||||
});
|
||||
let tokens = parse_cumulative_tokens(&json).unwrap();
|
||||
assert_eq!(tokens.cached_input, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_codex_session_files_nonexistent() {
|
||||
let files = collect_codex_session_files(Path::new("/nonexistent/path"));
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
// ── 模型名归一化测试 ──
|
||||
|
||||
#[test]
|
||||
fn test_normalize_codex_model_lowercase() {
|
||||
assert_eq!(normalize_codex_model("GLM-4.6"), "glm-4.6");
|
||||
assert_eq!(normalize_codex_model("DeepSeek-Chat"), "deepseek-chat");
|
||||
assert_eq!(normalize_codex_model("GPT-5.4"), "gpt-5.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_codex_model_strip_prefix() {
|
||||
assert_eq!(normalize_codex_model("openai/gpt-5.4"), "gpt-5.4");
|
||||
assert_eq!(
|
||||
normalize_codex_model("azure/gpt-5.2-codex"),
|
||||
"gpt-5.2-codex"
|
||||
);
|
||||
assert_eq!(normalize_codex_model("OPENAI/GPT-5.4"), "gpt-5.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_codex_model_strip_iso_date() {
|
||||
assert_eq!(normalize_codex_model("gpt-5.4-2026-03-05"), "gpt-5.4");
|
||||
assert_eq!(
|
||||
normalize_codex_model("gpt-5.4-pro-2026-03-05"),
|
||||
"gpt-5.4-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_codex_model_strip_compact_date() {
|
||||
assert_eq!(normalize_codex_model("gpt-5.4-20260305"), "gpt-5.4");
|
||||
assert_eq!(
|
||||
normalize_codex_model("claude-opus-4-6-20260206"),
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_codex_model_no_change() {
|
||||
assert_eq!(normalize_codex_model("gpt-5.4"), "gpt-5.4");
|
||||
assert_eq!(normalize_codex_model("gpt-5.2-codex"), "gpt-5.2-codex");
|
||||
assert_eq!(normalize_codex_model("o3"), "o3");
|
||||
assert_eq!(normalize_codex_model("deepseek-chat"), "deepseek-chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_codex_model_combined() {
|
||||
// prefix + uppercase + ISO date
|
||||
assert_eq!(
|
||||
normalize_codex_model("openai/GPT-5.4-2026-03-05"),
|
||||
"gpt-5.4"
|
||||
);
|
||||
// prefix + compact date
|
||||
assert_eq!(normalize_codex_model("openai/gpt-5.4-20260305"), "gpt-5.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cached_clamped_to_input() {
|
||||
// cached > input 的异常场景应被 min() 钳制
|
||||
let prev = Some(CumulativeTokens {
|
||||
input: 100,
|
||||
cached_input: 0,
|
||||
output: 50,
|
||||
});
|
||||
let current = CumulativeTokens {
|
||||
input: 110, // delta = 10
|
||||
cached_input: 80, // delta = 80(异常:大于 input delta)
|
||||
output: 60,
|
||||
};
|
||||
let delta = compute_delta(&prev, ¤t);
|
||||
// 钳制前:cached_input = 80, input = 10
|
||||
assert_eq!(delta.cached_input, 80);
|
||||
assert_eq!(delta.input, 10);
|
||||
// 实际钳制在调用侧:delta.cached_input.min(delta.input)
|
||||
let clamped = delta.cached_input.min(delta.input);
|
||||
assert_eq!(clamped, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Gemini CLI 会话日志使用追踪
|
||||
//!
|
||||
//! 从 ~/.gemini/tmp/<project_hash>/chats/session-*.json 中提取精确 token 使用数据。
|
||||
//!
|
||||
//! ## 数据流
|
||||
//! ```text
|
||||
//! ~/.gemini/tmp/*/chats/session-*.json → 全量解析 → 费用计算 → proxy_request_logs 表
|
||||
//! ```
|
||||
//!
|
||||
//! ## 与 Claude/Codex 解析器的差异
|
||||
//! - JSON 格式(非 JSONL):每个文件是单个 JSON 对象,包含 messages 数组
|
||||
//! - 无需 delta 计算:tokens 字段是 per-message 独立值
|
||||
//! - 无需状态恢复:不依赖前一条消息的累计值
|
||||
//! - 天然去重:每条消息有唯一 id 字段
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_dir;
|
||||
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use crate::services::session_usage::SessionSyncResult;
|
||||
use rust_decimal::Decimal;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 从 Gemini message 中提取的 token 数据
|
||||
#[derive(Debug)]
|
||||
struct GeminiTokens {
|
||||
input: u32,
|
||||
output: u32,
|
||||
cached: u32,
|
||||
thoughts: u32,
|
||||
}
|
||||
|
||||
/// 同步 Gemini 使用数据(从 JSON 会话日志)
|
||||
pub fn sync_gemini_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
|
||||
let gemini_dir = get_gemini_dir();
|
||||
|
||||
let files = collect_gemini_session_files(&gemini_dir);
|
||||
|
||||
let mut result = SessionSyncResult {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: files.len() as u32,
|
||||
errors: vec![],
|
||||
};
|
||||
|
||||
if files.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
for file_path in &files {
|
||||
match sync_single_gemini_file(db, file_path) {
|
||||
Ok((imported, skipped)) => {
|
||||
result.imported += imported;
|
||||
result.skipped += skipped;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Gemini 会话文件解析失败 {}: {e}", file_path.display());
|
||||
log::warn!("[GEMINI-SYNC] {msg}");
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.imported > 0 {
|
||||
log::info!(
|
||||
"[GEMINI-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件",
|
||||
result.imported,
|
||||
result.skipped,
|
||||
result.files_scanned
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 收集所有 Gemini 会话 JSON 文件
|
||||
fn collect_gemini_session_files(gemini_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
let tmp_dir = gemini_dir.join("tmp");
|
||||
if !tmp_dir.is_dir() {
|
||||
return files;
|
||||
}
|
||||
|
||||
// 遍历 tmp/<project_hash>/chats/session-*.json
|
||||
let project_dirs = match fs::read_dir(&tmp_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return files,
|
||||
};
|
||||
|
||||
for entry in project_dirs.flatten() {
|
||||
let chats_dir = entry.path().join("chats");
|
||||
if !chats_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chat_files = match fs::read_dir(&chats_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for file_entry in chat_files.flatten() {
|
||||
let path = file_entry.path();
|
||||
let is_session = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with("session-") && n.ends_with(".json"))
|
||||
.unwrap_or(false);
|
||||
if is_session {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
/// 同步单个 Gemini 会话 JSON 文件,返回 (imported, skipped)
|
||||
fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
// 获取文件元数据
|
||||
let metadata = fs::metadata(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
|
||||
let file_modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// 检查同步状态
|
||||
let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?;
|
||||
|
||||
// 文件未变化则跳过
|
||||
if file_modified <= last_modified {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
|
||||
// 读取并解析整个 JSON 文件
|
||||
let content = fs::read_to_string(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件: {e}")))?;
|
||||
let value: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| AppError::Config(format!("JSON 解析失败: {e}")))?;
|
||||
|
||||
// 提取顶层 sessionId
|
||||
let session_id = value
|
||||
.get("sessionId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 遍历 messages 数组
|
||||
let messages = match value.get("messages").and_then(|v| v.as_array()) {
|
||||
Some(msgs) => msgs,
|
||||
None => return Ok((0, 0)),
|
||||
};
|
||||
|
||||
let mut imported: u32 = 0;
|
||||
let mut skipped: u32 = 0;
|
||||
let mut gemini_msg_count: i64 = 0;
|
||||
|
||||
for msg in messages {
|
||||
// 只处理 type == "gemini" 的消息
|
||||
if msg.get("type").and_then(|t| t.as_str()) != Some("gemini") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取 tokens 对象
|
||||
let tokens_obj = match msg.get("tokens") {
|
||||
Some(t) if t.is_object() => t,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let tokens = parse_gemini_tokens(tokens_obj);
|
||||
if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0 {
|
||||
continue; // 跳过全零的空 token 消息
|
||||
}
|
||||
|
||||
gemini_msg_count += 1;
|
||||
|
||||
// 提取消息 ID 和模型
|
||||
let message_id = msg.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let model = msg
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let timestamp = msg.get("timestamp").and_then(|v| v.as_str());
|
||||
|
||||
// 生成唯一 request_id
|
||||
let session_id_str = session_id.as_deref().unwrap_or("unknown");
|
||||
let request_id = format!("gemini_session:{session_id_str}:{message_id}");
|
||||
|
||||
match insert_gemini_session_entry(
|
||||
db,
|
||||
&request_id,
|
||||
&tokens,
|
||||
model,
|
||||
session_id.as_deref(),
|
||||
timestamp,
|
||||
) {
|
||||
Ok(true) => imported += 1,
|
||||
Ok(false) => skipped += 1,
|
||||
Err(e) => {
|
||||
log::warn!("[GEMINI-SYNC] 插入失败 ({}): {e}", request_id);
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新同步状态
|
||||
update_sync_state(db, &file_path_str, file_modified, gemini_msg_count)?;
|
||||
|
||||
Ok((imported, skipped))
|
||||
}
|
||||
|
||||
/// 从 tokens JSON 对象中提取 token 数据
|
||||
fn parse_gemini_tokens(tokens: &serde_json::Value) -> GeminiTokens {
|
||||
GeminiTokens {
|
||||
input: tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
output: tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
cached: tokens.get("cached").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
thoughts: tokens.get("thoughts").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// 插入单条 Gemini 会话记录到 proxy_request_logs
|
||||
fn insert_gemini_session_entry(
|
||||
db: &Database,
|
||||
request_id: &str,
|
||||
tokens: &GeminiTokens,
|
||||
model: &str,
|
||||
session_id: Option<&str>,
|
||||
timestamp: Option<&str>,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 解析时间戳
|
||||
let created_at = timestamp
|
||||
.and_then(|ts| {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
// 合并 thoughts 到 output(思考 token 按输出计费)
|
||||
let output_tokens = tokens.output + tokens.thoughts;
|
||||
|
||||
// 计算费用
|
||||
let usage = TokenUsage {
|
||||
input_tokens: tokens.input,
|
||||
output_tokens,
|
||||
cache_read_tokens: tokens.cached,
|
||||
cache_creation_tokens: 0,
|
||||
model: Some(model.to_string()),
|
||||
};
|
||||
|
||||
let pricing = find_gemini_pricing(&conn, model);
|
||||
let multiplier = Decimal::from(1);
|
||||
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
|
||||
{
|
||||
Some(p) => {
|
||||
let cost = CostCalculator::calculate(&usage, &p, multiplier);
|
||||
(
|
||||
cost.input_cost.to_string(),
|
||||
cost.output_cost.to_string(),
|
||||
cost.cache_read_cost.to_string(),
|
||||
cost.cache_creation_cost.to_string(),
|
||||
cost.total_cost.to_string(),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// 使用 UPSERT:新记录插入,已存在记录更新 token 和费用(Gemini 全量重读可能携带更新值)
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at, data_source
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
|
||||
ON CONFLICT(request_id) DO UPDATE SET
|
||||
model = excluded.model,
|
||||
input_tokens = excluded.input_tokens,
|
||||
output_tokens = excluded.output_tokens,
|
||||
cache_read_tokens = excluded.cache_read_tokens,
|
||||
input_cost_usd = excluded.input_cost_usd,
|
||||
output_cost_usd = excluded.output_cost_usd,
|
||||
cache_read_cost_usd = excluded.cache_read_cost_usd,
|
||||
cache_creation_cost_usd = excluded.cache_creation_cost_usd,
|
||||
total_cost_usd = excluded.total_cost_usd
|
||||
WHERE input_tokens != excluded.input_tokens
|
||||
OR output_tokens != excluded.output_tokens
|
||||
OR cache_read_tokens != excluded.cache_read_tokens
|
||||
OR model != excluded.model",
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_gemini_session", // provider_id
|
||||
"gemini", // app_type
|
||||
model,
|
||||
model, // request_model = model
|
||||
tokens.input,
|
||||
output_tokens,
|
||||
tokens.cached,
|
||||
0i64, // cache_creation_tokens
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code
|
||||
Option::<String>::None, // error_message
|
||||
session_id.map(|s| s.to_string()),
|
||||
Some("gemini_session"), // provider_type
|
||||
1i64, // is_streaming
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"gemini_session", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?;
|
||||
|
||||
// changes() > 0 表示新插入或已更新,== 0 表示值完全相同(无实际变更)
|
||||
Ok(conn.changes() > 0)
|
||||
}
|
||||
|
||||
/// 获取文件的同步状态
|
||||
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
|
||||
rusqlite::params![file_path],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||
);
|
||||
Ok(result.unwrap_or((0, 0)))
|
||||
}
|
||||
|
||||
/// 更新文件的同步状态
|
||||
fn update_sync_state(
|
||||
db: &Database,
|
||||
file_path: &str,
|
||||
last_modified: i64,
|
||||
last_offset: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_path, last_modified, last_offset, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查找 Gemini 模型定价
|
||||
fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
// 精确匹配
|
||||
if let Some(pricing) = try_find_pricing(conn, model_id) {
|
||||
return Some(pricing);
|
||||
}
|
||||
|
||||
// LIKE 模糊匹配(兜底)
|
||||
let pattern = format!("{model_id}%");
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
|
||||
rusqlite::params![pattern],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
}
|
||||
|
||||
/// 精确匹配定价查询
|
||||
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_collect_gemini_session_files_nonexistent() {
|
||||
let files = collect_gemini_session_files(Path::new("/nonexistent/path"));
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gemini_tokens() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"input": 8522,
|
||||
"output": 29,
|
||||
"cached": 3138,
|
||||
"thoughts": 405,
|
||||
"tool": 0,
|
||||
"total": 8956
|
||||
});
|
||||
let tokens = parse_gemini_tokens(&json);
|
||||
assert_eq!(tokens.input, 8522);
|
||||
assert_eq!(tokens.output, 29);
|
||||
assert_eq!(tokens.cached, 3138);
|
||||
assert_eq!(tokens.thoughts, 405);
|
||||
// output + thoughts = 29 + 405 = 434(用于计费)
|
||||
assert_eq!(tokens.output + tokens.thoughts, 434);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gemini_tokens_missing_fields() {
|
||||
// 缺少某些字段时应返回 0
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"input": 100,
|
||||
"output": 50
|
||||
});
|
||||
let tokens = parse_gemini_tokens(&json);
|
||||
assert_eq!(tokens.input, 100);
|
||||
assert_eq!(tokens.output, 50);
|
||||
assert_eq!(tokens.cached, 0);
|
||||
assert_eq!(tokens.thoughts, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gemini_tokens_all_zero() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cached": 0,
|
||||
"thoughts": 0,
|
||||
"tool": 0,
|
||||
"total": 0
|
||||
});
|
||||
let tokens = parse_gemini_tokens(&json);
|
||||
assert_eq!(tokens.input, 0);
|
||||
assert_eq!(tokens.output, 0);
|
||||
// 全零(包括 cached=0)会被 sync 逻辑跳过
|
||||
assert!(
|
||||
tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gemini_tokens_cache_only_not_skipped() {
|
||||
// 纯缓存命中消息(input/output/thoughts=0 但 cached>0)不应被跳过
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cached": 5000,
|
||||
"thoughts": 0
|
||||
});
|
||||
let tokens = parse_gemini_tokens(&json);
|
||||
assert_eq!(tokens.cached, 5000);
|
||||
// 跳过条件:所有四个字段都为 0 才跳过
|
||||
let should_skip =
|
||||
tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0;
|
||||
assert!(!should_skip, "纯缓存命中记录不应被跳过");
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,17 @@ pub enum SyncMethod {
|
||||
Copy,
|
||||
}
|
||||
|
||||
/// Skill 存储位置(SSOT 目录选择)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillStorageLocation {
|
||||
/// CC Switch 管理目录 (~/.cc-switch/skills/)
|
||||
#[default]
|
||||
CcSwitch,
|
||||
/// Agent Skills 统一标准目录 (~/.agents/skills/)
|
||||
Unified,
|
||||
}
|
||||
|
||||
/// 可发现的技能(来自仓库)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoverableSkill {
|
||||
@@ -160,6 +171,81 @@ pub struct SkillUninstallResult {
|
||||
pub backup_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Skill 更新检测结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillUpdateInfo {
|
||||
/// Skill ID
|
||||
pub id: String,
|
||||
/// Skill 名称
|
||||
pub name: String,
|
||||
/// 当前本地哈希
|
||||
pub current_hash: Option<String>,
|
||||
/// 远程最新哈希
|
||||
pub remote_hash: String,
|
||||
}
|
||||
|
||||
/// Skill 存储位置迁移结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MigrationResult {
|
||||
pub migrated_count: usize,
|
||||
pub skipped_count: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
// ========== skills.sh API 类型 ==========
|
||||
|
||||
/// skills.sh API 原始响应
|
||||
///
|
||||
/// 注意:API 命名不一致(searchType 是 camelCase,duration_ms 是 snake_case),
|
||||
/// 因此不能用 rename_all,需要逐字段指定。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SkillsShApiResponse {
|
||||
pub query: String,
|
||||
#[serde(rename = "searchType")]
|
||||
#[allow(dead_code)]
|
||||
pub search_type: String,
|
||||
pub skills: Vec<SkillsShApiSkill>,
|
||||
pub count: usize,
|
||||
#[allow(dead_code)]
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// skills.sh API 原始技能条目
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SkillsShApiSkill {
|
||||
pub id: String,
|
||||
#[serde(rename = "skillId")]
|
||||
pub skill_id: String,
|
||||
pub name: String,
|
||||
pub installs: u64,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// skills.sh 搜索结果(返回给前端)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillsShSearchResult {
|
||||
pub skills: Vec<SkillsShDiscoverableSkill>,
|
||||
pub total_count: usize,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
/// skills.sh 可安装技能(返回给前端)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillsShDiscoverableSkill {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub directory: String,
|
||||
pub repo_owner: String,
|
||||
pub repo_name: String,
|
||||
pub repo_branch: String,
|
||||
pub installs: u64,
|
||||
pub readme_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillBackupEntry {
|
||||
@@ -389,9 +475,20 @@ impl SkillService {
|
||||
|
||||
// ========== 路径管理 ==========
|
||||
|
||||
/// 获取 SSOT 目录(~/.cc-switch/skills/)
|
||||
/// 获取 SSOT 目录(根据设置返回 ~/.cc-switch/skills/ 或 ~/.agents/skills/)
|
||||
pub fn get_ssot_dir() -> Result<PathBuf> {
|
||||
let dir = get_app_config_dir().join("skills");
|
||||
let location = crate::settings::get_skill_storage_location();
|
||||
let dir = match location {
|
||||
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
|
||||
SkillStorageLocation::Unified => {
|
||||
let home = dirs::home_dir().context(format_skill_error(
|
||||
"GET_HOME_DIR_FAILED",
|
||||
&[],
|
||||
Some("checkPermission"),
|
||||
))?;
|
||||
home.join(".agents").join("skills")
|
||||
}
|
||||
};
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
@@ -569,14 +666,28 @@ impl SkillService {
|
||||
repo_branch = used_branch;
|
||||
|
||||
// 复制到 SSOT
|
||||
let source = temp_dir.join(&source_rel);
|
||||
let mut source = temp_dir.join(&source_rel);
|
||||
if !source.exists() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &source.display().to_string())],
|
||||
Some("checkRepoUrl"),
|
||||
)));
|
||||
// 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md)
|
||||
let target_name = source_rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) {
|
||||
log::info!(
|
||||
"Skill directory '{}' not found at direct path, using fallback: {}",
|
||||
target_name,
|
||||
found.display()
|
||||
);
|
||||
source = found;
|
||||
} else {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &source.display().to_string())],
|
||||
Some("checkRepoUrl"),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
|
||||
@@ -632,6 +743,12 @@ impl SkillService {
|
||||
));
|
||||
|
||||
// 创建 InstalledSkill 记录
|
||||
// 计算内容哈希
|
||||
let content_hash = Self::compute_dir_hash(&dest).map(Some).unwrap_or_else(|e| {
|
||||
log::warn!("Failed to compute content hash for {}: {e}", install_name);
|
||||
None
|
||||
});
|
||||
|
||||
let installed_skill = InstalledSkill {
|
||||
id: skill.key.clone(),
|
||||
name: skill.name.clone(),
|
||||
@@ -647,6 +764,8 @@ impl SkillService {
|
||||
readme_url,
|
||||
apps: SkillApps::only(current_app),
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
@@ -706,6 +825,412 @@ impl SkillService {
|
||||
Ok(SkillUninstallResult { backup_path })
|
||||
}
|
||||
|
||||
// ========== 更新检测 ==========
|
||||
|
||||
/// 计算目录内容的 SHA-256 哈希
|
||||
///
|
||||
/// 递归遍历目录下所有非隐藏文件,按相对路径字典序排列,
|
||||
/// 将 "相对路径\0内容\0" 逐文件 feed 给同一个 hasher。
|
||||
pub fn compute_dir_hash(dir: &Path) -> Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut files: Vec<PathBuf> = Vec::new();
|
||||
Self::collect_files_for_hash(dir, dir, &mut files)?;
|
||||
files.sort();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
for file_path in &files {
|
||||
let relative = file_path.strip_prefix(dir).unwrap_or(file_path);
|
||||
let rel_str = relative.to_string_lossy().replace('\\', "/");
|
||||
hasher.update(rel_str.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
let content = fs::read(file_path)
|
||||
.with_context(|| format!("读取文件失败: {}", file_path.display()))?;
|
||||
hasher.update(&content);
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// 递归收集目录下所有非隐藏文件
|
||||
#[allow(clippy::only_used_in_recursion)]
|
||||
fn collect_files_for_hash(base: &Path, current: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
|
||||
let entries = fs::read_dir(current)
|
||||
.with_context(|| format!("读取目录失败: {}", current.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
Self::collect_files_for_hash(base, &path, files)?;
|
||||
} else {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查所有已安装 Skill 的更新
|
||||
///
|
||||
/// 仅检查有 repo_owner 的 Skill(本地 Skill 跳过),
|
||||
/// 按仓库分组下载,避免重复下载同一仓库。
|
||||
pub async fn check_updates(&self, db: &Arc<Database>) -> Result<Vec<SkillUpdateInfo>> {
|
||||
let skills = db.get_all_installed_skills()?;
|
||||
let mut updates = Vec::new();
|
||||
|
||||
// 按 (owner, name, branch) 分组
|
||||
let mut repo_groups: HashMap<(String, String, String), Vec<InstalledSkill>> =
|
||||
HashMap::new();
|
||||
|
||||
for skill in skills.into_values() {
|
||||
let (owner, name, branch) =
|
||||
match (&skill.repo_owner, &skill.repo_name, &skill.repo_branch) {
|
||||
(Some(o), Some(n), Some(b)) => (o.clone(), n.clone(), b.clone()),
|
||||
(Some(o), Some(n), None) => (o.clone(), n.clone(), "main".to_string()),
|
||||
_ => continue,
|
||||
};
|
||||
repo_groups
|
||||
.entry((owner, name, branch))
|
||||
.or_default()
|
||||
.push(skill);
|
||||
}
|
||||
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
|
||||
for ((owner, name, branch), group_skills) in &repo_groups {
|
||||
let repo = SkillRepo {
|
||||
owner: owner.clone(),
|
||||
name: name.clone(),
|
||||
branch: branch.clone(),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// 下载仓库 ZIP
|
||||
let (temp_dir, _used_branch) = match timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
self.download_repo(&repo),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("检查更新时下载 {}/{} 失败: {e}", owner, name);
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("检查更新时下载 {}/{} 超时", owner, name);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 扫描仓库中的所有 Skill 目录
|
||||
let mut remote_skills: Vec<DiscoverableSkill> = Vec::new();
|
||||
let _ = self.scan_dir_recursive(&temp_dir, &temp_dir, &repo, &mut remote_skills);
|
||||
|
||||
for skill in group_skills {
|
||||
// 在远程仓库中找到匹配的 Skill 目录
|
||||
let remote_match = remote_skills.iter().find(|rs| {
|
||||
// 匹配方式:安装名称的最后一段
|
||||
let remote_install_name =
|
||||
rs.directory.rsplit('/').next().unwrap_or(&rs.directory);
|
||||
remote_install_name.eq_ignore_ascii_case(&skill.directory)
|
||||
});
|
||||
|
||||
let remote_skill_dir = match remote_match {
|
||||
Some(rs) => temp_dir.join(&rs.directory),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !remote_skill_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remote_hash = match Self::compute_dir_hash(&remote_skill_dir) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
log::warn!("计算远程哈希失败 {}: {e}", skill.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 本地哈希:优先数据库,否则实时计算
|
||||
let local_hash = match &skill.content_hash {
|
||||
Some(h) => Some(h.clone()),
|
||||
None => {
|
||||
let local_dir = ssot_dir.join(&skill.directory);
|
||||
if local_dir.exists() {
|
||||
match Self::compute_dir_hash(&local_dir) {
|
||||
Ok(h) => {
|
||||
let _ = db.update_skill_hash(&skill.id, &h, 0);
|
||||
Some(h)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if local_hash.as_deref() != Some(&remote_hash) {
|
||||
updates.push(SkillUpdateInfo {
|
||||
id: skill.id.clone(),
|
||||
name: skill.name.clone(),
|
||||
current_hash: local_hash,
|
||||
remote_hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
/// 更新单个 Skill(重新下载并替换本地文件)
|
||||
pub async fn update_skill(&self, db: &Arc<Database>, skill_id: &str) -> Result<InstalledSkill> {
|
||||
let skill = db
|
||||
.get_installed_skill(skill_id)?
|
||||
.ok_or_else(|| anyhow!("Skill not found: {skill_id}"))?;
|
||||
|
||||
let (owner, name, branch) = match (&skill.repo_owner, &skill.repo_name) {
|
||||
(Some(o), Some(n)) => (
|
||||
o.clone(),
|
||||
n.clone(),
|
||||
skill
|
||||
.repo_branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| "main".to_string()),
|
||||
),
|
||||
_ => return Err(anyhow!("Cannot update local skill: {skill_id}")),
|
||||
};
|
||||
|
||||
let repo = SkillRepo {
|
||||
owner: owner.clone(),
|
||||
name: name.clone(),
|
||||
branch: branch.clone(),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
|
||||
// 下载仓库
|
||||
let (temp_dir, used_branch) = timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
self.download_repo(&repo),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(format_skill_error(
|
||||
"DOWNLOAD_TIMEOUT",
|
||||
&[("owner", &owner), ("name", &name), ("timeout", "60")],
|
||||
Some("checkNetwork"),
|
||||
))
|
||||
})??;
|
||||
|
||||
// 在解压的仓库中查找 Skill 源目录
|
||||
let mut remote_skills: Vec<DiscoverableSkill> = Vec::new();
|
||||
let _ = self.scan_dir_recursive(&temp_dir, &temp_dir, &repo, &mut remote_skills);
|
||||
|
||||
let remote_match = remote_skills
|
||||
.iter()
|
||||
.find(|rs| {
|
||||
let remote_install_name = rs.directory.rsplit('/').next().unwrap_or(&rs.directory);
|
||||
remote_install_name.eq_ignore_ascii_case(&skill.directory)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &skill.directory)],
|
||||
Some("checkRepoUrl"),
|
||||
))
|
||||
})?;
|
||||
|
||||
let source = temp_dir.join(&remote_match.directory);
|
||||
if !source.exists() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &source.display().to_string())],
|
||||
Some("checkRepoUrl"),
|
||||
)));
|
||||
}
|
||||
|
||||
// 备份旧文件
|
||||
let _ = Self::create_uninstall_backup(&skill);
|
||||
|
||||
// 删除旧 SSOT 目录并复制新文件
|
||||
let dest = ssot_dir.join(&skill.directory);
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
// 计算新哈希 + 解析新元数据
|
||||
let new_hash = Self::compute_dir_hash(&dest).ok();
|
||||
let skill_md = dest.join("SKILL.md");
|
||||
let (new_name, new_description) = Self::read_skill_name_desc(&skill_md, &skill.directory);
|
||||
|
||||
// 更新 readme_url
|
||||
let doc_path = skill
|
||||
.readme_url
|
||||
.as_deref()
|
||||
.and_then(Self::extract_doc_path_from_url)
|
||||
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
|
||||
let readme_url = Some(Self::build_skill_doc_url(
|
||||
&owner,
|
||||
&name,
|
||||
&used_branch,
|
||||
&doc_path,
|
||||
));
|
||||
|
||||
let updated_skill = InstalledSkill {
|
||||
id: skill.id.clone(),
|
||||
name: new_name,
|
||||
description: new_description,
|
||||
directory: skill.directory.clone(),
|
||||
repo_owner: skill.repo_owner.clone(),
|
||||
repo_name: skill.repo_name.clone(),
|
||||
repo_branch: Some(used_branch),
|
||||
readme_url,
|
||||
apps: skill.apps.clone(),
|
||||
installed_at: skill.installed_at,
|
||||
content_hash: new_hash,
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
|
||||
db.save_skill(&updated_skill)?;
|
||||
|
||||
// 同步到所有已启用的应用目录
|
||||
for app in updated_skill.apps.enabled_apps() {
|
||||
if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) {
|
||||
log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Skill {} 更新成功", updated_skill.name);
|
||||
Ok(updated_skill)
|
||||
}
|
||||
|
||||
/// 为缺少 content_hash 的已安装 Skill 补算哈希
|
||||
pub fn backfill_content_hashes(db: &Arc<Database>) -> Result<usize> {
|
||||
let skills = db.get_all_installed_skills()?;
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let mut count = 0;
|
||||
|
||||
for skill in skills.values() {
|
||||
if skill.content_hash.is_some() {
|
||||
continue;
|
||||
}
|
||||
let skill_dir = ssot_dir.join(&skill.directory);
|
||||
if !skill_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
match Self::compute_dir_hash(&skill_dir) {
|
||||
Ok(hash) => {
|
||||
let _ = db.update_skill_hash(&skill.id, &hash, 0);
|
||||
count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("补算哈希失败 {}: {e}", skill.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
log::info!("已为 {count} 个 Skill 补算内容哈希");
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件)
|
||||
///
|
||||
/// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。
|
||||
pub fn migrate_storage(
|
||||
db: &Arc<Database>,
|
||||
target: SkillStorageLocation,
|
||||
) -> Result<MigrationResult> {
|
||||
let current = crate::settings::get_skill_storage_location();
|
||||
if current == target {
|
||||
return Ok(MigrationResult {
|
||||
migrated_count: 0,
|
||||
skipped_count: 0,
|
||||
errors: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 解析旧目录和新目录(不改设置)
|
||||
let old_dir = Self::get_ssot_dir()?;
|
||||
let new_dir = match target {
|
||||
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
|
||||
SkillStorageLocation::Unified => {
|
||||
let home = dirs::home_dir().context("Cannot determine home directory")?;
|
||||
home.join(".agents").join("skills")
|
||||
}
|
||||
};
|
||||
fs::create_dir_all(&new_dir)?;
|
||||
|
||||
// 2. 逐个移动 skill 目录
|
||||
let skills = db.get_all_installed_skills()?;
|
||||
let mut result = MigrationResult {
|
||||
migrated_count: 0,
|
||||
skipped_count: 0,
|
||||
errors: vec![],
|
||||
};
|
||||
|
||||
for skill in skills.values() {
|
||||
let src = old_dir.join(&skill.directory);
|
||||
let dst = new_dir.join(&skill.directory);
|
||||
|
||||
if !src.exists() {
|
||||
result.skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
if dst.exists() {
|
||||
result.skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 优先 rename(同文件系统原子操作),失败则 copy+delete
|
||||
match fs::rename(&src, &dst) {
|
||||
Ok(()) => result.migrated_count += 1,
|
||||
Err(_) => match Self::copy_dir_recursive(&src, &dst) {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_dir_all(&src);
|
||||
result.migrated_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("{}: {e}", skill.directory));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 文件移动完成后才持久化设置
|
||||
crate::settings::set_skill_storage_location(target)?;
|
||||
|
||||
// 4. 刷新所有应用目录的 symlink(指向新 SSOT)
|
||||
for app in AppType::all() {
|
||||
let _ = Self::sync_to_app(db, &app);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Skill 存储迁移完成: {} 迁移, {} 跳过, {} 错误",
|
||||
result.migrated_count,
|
||||
result.skipped_count,
|
||||
result.errors.len()
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn list_backups() -> Result<Vec<SkillBackupEntry>> {
|
||||
let backup_dir = Self::get_backup_dir()?;
|
||||
let mut entries = Vec::new();
|
||||
@@ -800,9 +1325,13 @@ impl SkillService {
|
||||
let mut restored_skill = metadata.skill;
|
||||
restored_skill.installed_at = Utc::now().timestamp();
|
||||
restored_skill.apps = SkillApps::only(current_app);
|
||||
restored_skill.updated_at = 0;
|
||||
|
||||
Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?;
|
||||
|
||||
// 重新计算内容哈希
|
||||
restored_skill.content_hash = Self::compute_dir_hash(&restore_path).ok();
|
||||
|
||||
if let Err(err) = db.save_skill(&restored_skill) {
|
||||
let _ = fs::remove_dir_all(&restore_path);
|
||||
return Err(err.into());
|
||||
@@ -991,6 +1520,10 @@ impl SkillService {
|
||||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||
build_repo_info_from_lock(&agents_lock, &dir_name);
|
||||
|
||||
// 计算内容哈希
|
||||
let ssot_skill_dir = ssot_dir.join(&dir_name);
|
||||
let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok();
|
||||
|
||||
// 创建记录
|
||||
let skill = InstalledSkill {
|
||||
id,
|
||||
@@ -1003,6 +1536,8 @@ impl SkillService {
|
||||
readme_url,
|
||||
apps,
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
@@ -1514,6 +2049,38 @@ impl SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在目录树中查找名称匹配且包含 SKILL.md 的子目录
|
||||
///
|
||||
/// 用于 skills.sh 安装回退:API 只返回 skillId(如 "find-skills"),
|
||||
/// 但实际文件可能在仓库子目录中(如 "skills/find-skills")。
|
||||
fn find_skill_dir_by_name(root: &Path, target_name: &str) -> Option<PathBuf> {
|
||||
fn walk(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
|
||||
if depth > 3 {
|
||||
return None;
|
||||
}
|
||||
let entries = fs::read_dir(dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if name_str.eq_ignore_ascii_case(target) && path.join("SKILL.md").exists() {
|
||||
return Some(path);
|
||||
}
|
||||
if let Some(found) = walk(&path, target, depth + 1) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
walk(root, target_name, 0)
|
||||
}
|
||||
|
||||
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||
let mut seen = HashMap::new();
|
||||
@@ -1965,6 +2532,9 @@ impl SkillService {
|
||||
}
|
||||
Self::copy_dir_recursive(&skill_dir, &dest)?;
|
||||
|
||||
// 计算内容哈希
|
||||
let content_hash = Self::compute_dir_hash(&dest).ok();
|
||||
|
||||
// 创建 InstalledSkill 记录
|
||||
let skill = InstalledSkill {
|
||||
id: format!("local:{install_name}"),
|
||||
@@ -1977,6 +2547,8 @@ impl SkillService {
|
||||
readme_url: None,
|
||||
apps: SkillApps::only(current_app),
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
@@ -2116,6 +2688,67 @@ impl SkillService {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== skills.sh 搜索 ==========
|
||||
|
||||
/// 搜索 skills.sh 公共目录
|
||||
pub async fn search_skills_sh(
|
||||
query: &str,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<SkillsShSearchResult> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let url = url::Url::parse_with_params(
|
||||
"https://skills.sh/api/search",
|
||||
&[
|
||||
("q", query),
|
||||
("limit", &limit.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
],
|
||||
)?;
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<SkillsShApiResponse>()
|
||||
.await?;
|
||||
|
||||
let skills = resp
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
let parts: Vec<&str> = s.source.splitn(2, '/').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let (owner, repo) = (parts[0].to_string(), parts[1].to_string());
|
||||
// 过滤非 GitHub 来源(如 "skills.volces.com"、"mcp-hub.momenta.works")
|
||||
if owner.contains('.') || repo.contains('.') {
|
||||
return None;
|
||||
}
|
||||
Some(SkillsShDiscoverableSkill {
|
||||
key: s.id,
|
||||
name: s.name,
|
||||
directory: s.skill_id.clone(),
|
||||
repo_owner: owner.clone(),
|
||||
repo_name: repo.clone(),
|
||||
repo_branch: "main".to_string(),
|
||||
installs: s.installs,
|
||||
readme_url: Some(format!("https://github.com/{}/{}", owner, repo)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(SkillsShSearchResult {
|
||||
skills,
|
||||
total_count: resp.count,
|
||||
query: resp.query,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 迁移支持 ==========
|
||||
@@ -2288,6 +2921,8 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||
build_repo_info_from_lock(&agents_lock, &directory);
|
||||
|
||||
let content_hash = SkillService::compute_dir_hash(&ssot_path).ok();
|
||||
|
||||
let skill = InstalledSkill {
|
||||
id,
|
||||
name,
|
||||
@@ -2299,6 +2934,8 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
readme_url,
|
||||
apps,
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
db.save_skill(&skill)?;
|
||||
|
||||
@@ -199,6 +199,14 @@ impl StreamCheckService {
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
let start = Instant::now();
|
||||
|
||||
// OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同
|
||||
// (baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api`
|
||||
// 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。
|
||||
if matches!(app_type, AppType::OpenCode | AppType::OpenClaw) {
|
||||
return Self::check_once_without_adapter(app_type, provider, config, start).await;
|
||||
}
|
||||
|
||||
let adapter = get_adapter(app_type);
|
||||
|
||||
let base_url = match base_url_override {
|
||||
@@ -231,6 +239,7 @@ impl StreamCheckService {
|
||||
request_timeout,
|
||||
provider,
|
||||
claude_api_format_override.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -254,24 +263,13 @@ impl StreamCheckService {
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"opencode_no_stream_check",
|
||||
"OpenCode 暂不支持健康检查",
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"openclaw_no_stream_check",
|
||||
"OpenClaw 暂不支持健康检查",
|
||||
"OpenClaw does not support health check yet",
|
||||
));
|
||||
AppType::OpenCode | AppType::OpenClaw => {
|
||||
// Already handled via early dispatch above
|
||||
unreachable!("OpenCode/OpenClaw 已通过 check_once_without_adapter 处理")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,6 +311,10 @@ impl StreamCheckService {
|
||||
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
|
||||
/// - "openai_responses": OpenAI Responses API (/v1/responses)
|
||||
/// - "gemini_native": Gemini Native streamGenerateContent
|
||||
///
|
||||
/// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw
|
||||
/// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers`
|
||||
/// 读取),在所有内置 header 之后追加,用于覆盖或补充(例如自定义 User-Agent)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
@@ -323,6 +325,7 @@ impl StreamCheckService {
|
||||
timeout: std::time::Duration,
|
||||
provider: &Provider,
|
||||
claude_api_format_override: Option<&str>,
|
||||
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
|
||||
@@ -367,8 +370,16 @@ impl StreamCheckService {
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记,
|
||||
// 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。
|
||||
let is_codex_oauth = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id))
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_gemini_native {
|
||||
anthropic_to_gemini(anthropic_body)
|
||||
@@ -475,6 +486,15 @@ impl StreamCheckService {
|
||||
.header("connection", "keep-alive");
|
||||
}
|
||||
|
||||
// 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent)
|
||||
if let Some(headers) = extra_headers {
|
||||
for (key, value) in headers {
|
||||
if let Some(v) = value.as_str() {
|
||||
request_builder = request_builder.header(key.as_str(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = request_builder
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
@@ -595,6 +615,7 @@ impl StreamCheckService {
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
|
||||
@@ -614,11 +635,22 @@ impl StreamCheckService {
|
||||
}]
|
||||
});
|
||||
|
||||
let response = client
|
||||
let mut request_builder = client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &auth.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Accept", "text/event-stream");
|
||||
|
||||
// 供应商自定义 headers 最后追加
|
||||
if let Some(headers) = extra_headers {
|
||||
for (key, value) in headers {
|
||||
if let Some(v) = value.as_str() {
|
||||
request_builder = request_builder.header(key.as_str(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = request_builder
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
@@ -643,6 +675,451 @@ impl StreamCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenCode / OpenClaw 的独立分发入口(绕过 `get_adapter`)
|
||||
///
|
||||
/// 这两个应用的 `settings_config` 与 Claude/Codex/Gemini 完全不同:
|
||||
/// - OpenClaw: `{ baseUrl, apiKey, api, models: [...] }`,`api` 字段标识协议
|
||||
/// - OpenCode: `{ npm, options: { baseURL, apiKey }, models: {...} }`,`npm` 字段标识协议
|
||||
///
|
||||
/// 因此不能复用 `get_adapter`(会 fallback 到 CodexAdapter 而提取失败),
|
||||
/// 改为独立解析 base_url/api_key/协议,再分发到现有的 check_*_stream 函数。
|
||||
async fn check_once_without_adapter(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
start: Instant,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let client = crate::proxy::http_client::get_for_provider(proxy_config);
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
let test_prompt = &config.test_prompt;
|
||||
|
||||
let result = match app_type {
|
||||
AppType::OpenClaw => {
|
||||
Self::check_openclaw_stream(
|
||||
&client,
|
||||
provider,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
Self::check_opencode_stream(
|
||||
&client,
|
||||
provider,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"),
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
Ok(Self::build_stream_check_result(
|
||||
result,
|
||||
response_time,
|
||||
config.degraded_threshold_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// 将 check_*_stream 的原始结果包装成 StreamCheckResult
|
||||
///
|
||||
/// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。
|
||||
fn build_stream_check_result(
|
||||
result: Result<(u16, String), AppError>,
|
||||
response_time: u64,
|
||||
degraded_threshold_ms: u64,
|
||||
) -> StreamCheckResult {
|
||||
let tested_at = chrono::Utc::now().timestamp();
|
||||
match result {
|
||||
Ok((status_code, model)) => StreamCheckResult {
|
||||
status: Self::determine_status(response_time, degraded_threshold_ms),
|
||||
success: true,
|
||||
message: "Check succeeded".to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: Some(status_code),
|
||||
model_used: model,
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
},
|
||||
Err(e) => StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenClaw 流式检查分发器
|
||||
///
|
||||
/// 根据 `settings_config.api` 字段分发到对应协议的检查器。
|
||||
/// 取值参见 `openclawApiProtocols` (前端 openclawProviderPresets.ts):
|
||||
/// - `openai-completions` → check_claude_stream + api_format="openai_chat"
|
||||
/// - `openai-responses` → check_claude_stream + api_format="openai_responses"
|
||||
/// - `anthropic-messages` → check_claude_stream + api_format="anthropic" (ClaudeAuth 策略)
|
||||
/// - `google-generative-ai` → check_gemini_stream (Google API Key 策略)
|
||||
/// - `bedrock-converse-stream` → 不支持(需要 AWS SigV4 签名)
|
||||
async fn check_openclaw_stream(
|
||||
client: &Client,
|
||||
provider: &Provider,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
// 自定义认证头(如 Longcat 的 `apikey` 头)不走标准 Bearer,
|
||||
// 具体头名由 OpenClaw 网关内部决定,cc-switch 无法准确构造,
|
||||
// 因此直接返回友好错误而不是让用户看到一个误导性的 401。
|
||||
if Self::openclaw_uses_auth_header(provider) {
|
||||
return Err(AppError::localized(
|
||||
"openclaw_auth_header_not_supported",
|
||||
"该供应商使用自定义认证头,暂不支持流式健康检查。建议直接通过 OpenClaw 测试。",
|
||||
"This provider uses a custom auth header; stream health check is not supported. Please test it directly via OpenClaw.",
|
||||
));
|
||||
}
|
||||
|
||||
let base_url = Self::extract_openclaw_base_url(provider)?;
|
||||
let api_key = Self::extract_openclaw_api_key(provider)?;
|
||||
let api = Self::extract_openclaw_protocol(provider);
|
||||
let extra_headers = Self::extract_openclaw_headers(provider);
|
||||
|
||||
match api.as_deref() {
|
||||
Some("openai-completions") => {
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
|
||||
Self::check_claude_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
provider,
|
||||
Some("openai_chat"),
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("openai-responses") => {
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
|
||||
Self::check_claude_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
provider,
|
||||
Some("openai_responses"),
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("anthropic-messages") => {
|
||||
// 使用 ClaudeAuth(Bearer-only)以兼容 Claude 中转服务。
|
||||
// 某些中转同时收到 Authorization 和 x-api-key 会报错,ClaudeAuth
|
||||
// 策略保证只下发 Bearer。官方 Anthropic 也接受纯 Bearer。
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth);
|
||||
Self::check_claude_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
provider,
|
||||
Some("anthropic"),
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("google-generative-ai") => {
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::Google);
|
||||
Self::check_gemini_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("bedrock-converse-stream") => Err(AppError::localized(
|
||||
"openclaw_bedrock_not_supported",
|
||||
"AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenClaw 验证连通性。",
|
||||
"AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenClaw.",
|
||||
)),
|
||||
Some(other) => Err(AppError::localized(
|
||||
"openclaw_protocol_not_yet_supported",
|
||||
format!("OpenClaw 暂不支持协议: {other}"),
|
||||
format!("OpenClaw protocol not yet supported: {other}"),
|
||||
)),
|
||||
None => Err(AppError::localized(
|
||||
"openclaw_protocol_missing",
|
||||
"OpenClaw 供应商缺少 api 字段",
|
||||
"OpenClaw provider is missing the `api` field",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断 OpenClaw 供应商是否使用自定义认证头(`authHeader: true`)
|
||||
fn openclaw_uses_auth_header(provider: &Provider) -> bool {
|
||||
provider
|
||||
.settings_config
|
||||
.get("authHeader")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 提取 OpenClaw 供应商的自定义 headers(来自 `settings_config.headers`)
|
||||
fn extract_openclaw_headers(
|
||||
provider: &Provider,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("headers")
|
||||
.and_then(|v| v.as_object())
|
||||
.filter(|m| !m.is_empty())
|
||||
}
|
||||
|
||||
fn extract_openclaw_base_url(provider: &Provider) -> Result<String, AppError> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"openclaw_base_url_missing",
|
||||
"OpenClaw 供应商缺少 baseUrl",
|
||||
"OpenClaw provider is missing `baseUrl`",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_openclaw_api_key(provider: &Provider) -> Result<String, AppError> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"openclaw_api_key_missing",
|
||||
"OpenClaw 供应商缺少 apiKey",
|
||||
"OpenClaw provider is missing `apiKey`",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_openclaw_protocol(provider: &Provider) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("api")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// OpenCode 流式检查分发器
|
||||
///
|
||||
/// OpenCode 用 `npm` 字段(AI SDK 包名)隐式指定协议。映射关系参见
|
||||
/// `opencodeNpmPackages` (前端 opencodeProviderPresets.ts):
|
||||
/// - `@ai-sdk/openai-compatible` → check_claude_stream + api_format="openai_chat"
|
||||
/// - `@ai-sdk/openai` → check_claude_stream + api_format="openai_responses"
|
||||
/// - `@ai-sdk/anthropic` → check_claude_stream + api_format="anthropic"
|
||||
/// - `@ai-sdk/google` → check_gemini_stream (Google API Key 策略)
|
||||
/// - `@ai-sdk/amazon-bedrock` → 不支持(需要 AWS SigV4 签名)
|
||||
///
|
||||
/// URL/API Key 存放在 `settings_config.options.{baseURL,apiKey}`,注意
|
||||
/// `baseURL` 大写 L(与 OpenClaw 的 `baseUrl` 首字母小写 u 不同)。
|
||||
async fn check_opencode_stream(
|
||||
client: &Client,
|
||||
provider: &Provider,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let npm = Self::extract_opencode_npm(provider);
|
||||
// 若用户未显式填 baseURL,则根据 npm 回退到 AI SDK 包自带的默认端点
|
||||
let base_url = Self::resolve_opencode_base_url(provider, npm.as_deref())?;
|
||||
let api_key = Self::extract_opencode_api_key(provider)?;
|
||||
let extra_headers = Self::extract_opencode_headers(provider);
|
||||
|
||||
match npm.as_deref() {
|
||||
Some("@ai-sdk/openai-compatible") => {
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
|
||||
Self::check_claude_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
provider,
|
||||
Some("openai_chat"),
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("@ai-sdk/openai") => {
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
|
||||
Self::check_claude_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
provider,
|
||||
Some("openai_responses"),
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("@ai-sdk/anthropic") => {
|
||||
// 见 check_openclaw_stream 对 anthropic-messages 的注释:
|
||||
// 用 ClaudeAuth(Bearer-only)兼容中转服务。
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth);
|
||||
Self::check_claude_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
provider,
|
||||
Some("anthropic"),
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("@ai-sdk/google") => {
|
||||
let auth = AuthInfo::new(api_key, AuthStrategy::Google);
|
||||
Self::check_gemini_stream(
|
||||
client,
|
||||
&base_url,
|
||||
&auth,
|
||||
model,
|
||||
test_prompt,
|
||||
timeout,
|
||||
extra_headers,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some("@ai-sdk/amazon-bedrock") => Err(AppError::localized(
|
||||
"opencode_bedrock_not_supported",
|
||||
"AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenCode 验证连通性。",
|
||||
"AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenCode.",
|
||||
)),
|
||||
Some(other) => Err(AppError::localized(
|
||||
"opencode_npm_not_yet_supported",
|
||||
format!("OpenCode 暂不支持 SDK 包: {other}"),
|
||||
format!("OpenCode SDK package not yet supported: {other}"),
|
||||
)),
|
||||
None => Err(AppError::localized(
|
||||
"opencode_npm_missing",
|
||||
"OpenCode 供应商缺少 npm 字段",
|
||||
"OpenCode provider is missing the `npm` field",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 OpenCode 的实际 SDK 包特性确定 baseURL:
|
||||
/// - 用户显式填写的 `options.baseURL` 总是优先
|
||||
/// - 否则根据 `npm` 返回 AI SDK 包自带的默认端点
|
||||
/// - `@ai-sdk/openai-compatible` 没有默认端点,必须显式填
|
||||
///
|
||||
/// 注意:这里的默认端点对应 AI SDK 包的行为(例如 `@ai-sdk/openai`
|
||||
/// 自带 `/v1` 路径后缀),与 `proxy/providers/mod.rs` 里的
|
||||
/// `ProviderType::default_endpoint()` 语义不同——后者是代理层的上游
|
||||
/// 默认值,不带 `/v1`。两者维护的是不同系统的默认值,不能简单共享。
|
||||
fn resolve_opencode_base_url(
|
||||
provider: &Provider,
|
||||
npm: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
if let Some(explicit) = Self::extract_opencode_base_url(provider) {
|
||||
return Ok(explicit);
|
||||
}
|
||||
|
||||
let fallback = match npm {
|
||||
Some("@ai-sdk/openai") => Some("https://api.openai.com/v1"),
|
||||
Some("@ai-sdk/anthropic") => Some("https://api.anthropic.com"),
|
||||
Some("@ai-sdk/google") => Some("https://generativelanguage.googleapis.com"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
fallback.map(|s| s.to_string()).ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"opencode_base_url_missing",
|
||||
"OpenCode 供应商缺少 options.baseURL,且当前 SDK 包没有默认端点",
|
||||
"OpenCode provider is missing `options.baseURL` and the SDK package has no default endpoint",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_opencode_base_url(provider: &Provider) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("options")
|
||||
.and_then(|v| v.get("baseURL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 提取 OpenCode 供应商的自定义 headers(来自 `settings_config.options.headers`)
|
||||
fn extract_opencode_headers(
|
||||
provider: &Provider,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("options")
|
||||
.and_then(|v| v.get("headers"))
|
||||
.and_then(|v| v.as_object())
|
||||
.filter(|m| !m.is_empty())
|
||||
}
|
||||
|
||||
fn extract_opencode_api_key(provider: &Provider) -> Result<String, AppError> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("options")
|
||||
.and_then(|v| v.get("apiKey"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"opencode_api_key_missing",
|
||||
"OpenCode 供应商缺少 options.apiKey",
|
||||
"OpenCode provider is missing `options.apiKey`",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_opencode_npm(provider: &Provider) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("npm")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus {
|
||||
if latency_ms <= threshold {
|
||||
HealthStatus::Operational
|
||||
@@ -846,6 +1323,127 @@ impl StreamCheckService {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_provider(settings_config: serde_json::Value) -> Provider {
|
||||
Provider::with_id(
|
||||
"test".to_string(),
|
||||
"Test".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_uses_auth_header_true() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
"baseUrl": "https://api.longcat.chat/v1",
|
||||
"apiKey": "k",
|
||||
"api": "openai-completions",
|
||||
"authHeader": true,
|
||||
}));
|
||||
assert!(StreamCheckService::openclaw_uses_auth_header(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_uses_auth_header_default_false() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
"baseUrl": "https://api.deepseek.com/v1",
|
||||
"apiKey": "k",
|
||||
"api": "openai-completions",
|
||||
}));
|
||||
assert!(!StreamCheckService::openclaw_uses_auth_header(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_opencode_base_url_explicit_wins() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": { "baseURL": "https://proxy.local/v1", "apiKey": "k" },
|
||||
"models": {},
|
||||
}));
|
||||
let resolved =
|
||||
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap();
|
||||
assert_eq!(resolved, "https://proxy.local/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_opencode_base_url_falls_back_for_known_npm() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": { "apiKey": "k" },
|
||||
"models": {},
|
||||
}));
|
||||
let resolved =
|
||||
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap();
|
||||
assert_eq!(resolved, "https://api.openai.com/v1");
|
||||
|
||||
let p2 = make_provider(serde_json::json!({
|
||||
"npm": "@ai-sdk/anthropic",
|
||||
"options": { "apiKey": "k" },
|
||||
"models": {},
|
||||
}));
|
||||
let resolved2 =
|
||||
StreamCheckService::resolve_opencode_base_url(&p2, Some("@ai-sdk/anthropic")).unwrap();
|
||||
assert_eq!(resolved2, "https://api.anthropic.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_opencode_base_url_errors_for_openai_compatible_without_url() {
|
||||
// @ai-sdk/openai-compatible 没有默认端点,必须显式填
|
||||
let p = make_provider(serde_json::json!({
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": { "apiKey": "k" },
|
||||
"models": {},
|
||||
}));
|
||||
let result =
|
||||
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai-compatible"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_openclaw_headers_preserves_map() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
"baseUrl": "https://example.com/v1",
|
||||
"apiKey": "k",
|
||||
"api": "openai-completions",
|
||||
"headers": { "User-Agent": "MyBot/1.0", "X-Trace": "abc" },
|
||||
}));
|
||||
let headers = StreamCheckService::extract_openclaw_headers(&p).unwrap();
|
||||
assert_eq!(
|
||||
headers.get("User-Agent").and_then(|v| v.as_str()),
|
||||
Some("MyBot/1.0")
|
||||
);
|
||||
assert_eq!(headers.get("X-Trace").and_then(|v| v.as_str()), Some("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_openclaw_headers_ignores_empty_map() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
"baseUrl": "https://example.com/v1",
|
||||
"apiKey": "k",
|
||||
"api": "openai-completions",
|
||||
"headers": {},
|
||||
}));
|
||||
assert!(StreamCheckService::extract_openclaw_headers(&p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_opencode_headers_from_options() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "https://example.com/v1",
|
||||
"apiKey": "k",
|
||||
"headers": { "X-Custom": "yes" },
|
||||
},
|
||||
"models": {},
|
||||
}));
|
||||
let headers = StreamCheckService::extract_opencode_headers(&p).unwrap();
|
||||
assert_eq!(
|
||||
headers.get("X-Custom").and_then(|v| v.as_str()),
|
||||
Some("yes")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_status() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -60,7 +60,7 @@ pub struct SubscriptionQuota {
|
||||
}
|
||||
|
||||
impl SubscriptionQuota {
|
||||
fn not_found(tool: &str) -> Self {
|
||||
pub(crate) fn not_found(tool: &str) -> Self {
|
||||
Self {
|
||||
tool: tool.to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
@@ -73,7 +73,7 @@ impl SubscriptionQuota {
|
||||
}
|
||||
}
|
||||
|
||||
fn error(tool: &str, status: CredentialStatus, message: String) -> Self {
|
||||
pub(crate) fn error(tool: &str, status: CredentialStatus, message: String) -> Self {
|
||||
Self {
|
||||
tool: tool.to_string(),
|
||||
credential_status: status,
|
||||
@@ -621,8 +621,17 @@ fn unix_ts_to_iso(ts: i64) -> Option<String> {
|
||||
chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.to_rfc3339())
|
||||
}
|
||||
|
||||
/// 查询 Codex 官方订阅额度
|
||||
async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> SubscriptionQuota {
|
||||
/// 查询 Codex / ChatGPT 反代订阅额度
|
||||
///
|
||||
/// 参数化 `tool_label` 和 `expired_message` 让该函数可被两个调用点共用:
|
||||
/// - `"codex"` + "Please re-login with Codex CLI."(CLI 凭据路径)
|
||||
/// - `"codex_oauth"` + "Please re-login via cc-switch."(cc-switch 自管 OAuth 路径)
|
||||
pub(crate) async fn query_codex_quota(
|
||||
access_token: &str,
|
||||
account_id: Option<&str>,
|
||||
tool_label: &str,
|
||||
expired_message: &str,
|
||||
) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let mut req = client
|
||||
@@ -639,7 +648,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return SubscriptionQuota::error(
|
||||
"codex",
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("Network error: {e}"),
|
||||
);
|
||||
@@ -650,16 +659,16 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota::error(
|
||||
"codex",
|
||||
tool_label,
|
||||
CredentialStatus::Expired,
|
||||
format!("Authentication failed (HTTP {status}). Please re-login with Codex CLI."),
|
||||
format!("{expired_message} (HTTP {status})"),
|
||||
);
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return SubscriptionQuota::error(
|
||||
"codex",
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("API error (HTTP {status}): {body}"),
|
||||
);
|
||||
@@ -669,7 +678,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return SubscriptionQuota::error(
|
||||
"codex",
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("Failed to parse API response: {e}"),
|
||||
);
|
||||
@@ -697,7 +706,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "codex".to_string(),
|
||||
tool: tool_label.to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
@@ -1221,7 +1230,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
||||
CredentialStatus::Expired => {
|
||||
// 即使可能过期也尝试调用 API
|
||||
if let Some(token) = token {
|
||||
let result = query_codex_quota(&token, account_id.as_deref()).await;
|
||||
let result = query_codex_quota(
|
||||
&token,
|
||||
account_id.as_deref(),
|
||||
"codex",
|
||||
"Authentication failed. Please re-login with Codex CLI.",
|
||||
)
|
||||
.await;
|
||||
if result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
@@ -1234,7 +1249,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
||||
}
|
||||
CredentialStatus::Valid => {
|
||||
let token = token.expect("token must be Some when status is Valid");
|
||||
Ok(query_codex_quota(&token, account_id.as_deref()).await)
|
||||
Ok(query_codex_quota(
|
||||
&token,
|
||||
account_id.as_deref(),
|
||||
"codex",
|
||||
"Authentication failed. Please re-login with Codex CLI.",
|
||||
)
|
||||
.await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,21 @@ pub struct RequestLogDetail {
|
||||
pub status_code: u16,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data_source: Option<String>,
|
||||
}
|
||||
|
||||
/// SQL fragment: resolve provider_name with fallback for session-based entries.
|
||||
/// Session logs use placeholder provider_ids (_session, _codex_session, _gemini_session)
|
||||
/// that don't exist in the providers table — this COALESCE gives them readable names.
|
||||
fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
|
||||
format!(
|
||||
"COALESCE({provider_alias}.name, CASE {log_alias}.provider_id \
|
||||
WHEN '_session' THEN 'Claude (Session)' \
|
||||
WHEN '_codex_session' THEN 'Codex (Session)' \
|
||||
WHEN '_gemini_session' THEN 'Gemini (Session)' \
|
||||
ELSE {log_alias}.provider_id END)"
|
||||
)
|
||||
}
|
||||
|
||||
impl Database {
|
||||
@@ -121,44 +136,54 @@ impl Database {
|
||||
&self,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() {
|
||||
let mut conditions = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
// Build detail WHERE clause
|
||||
let mut conditions = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(start) = start_date {
|
||||
conditions.push("created_at >= ?");
|
||||
params.push(start);
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
conditions.push("created_at <= ?");
|
||||
params.push(end);
|
||||
}
|
||||
if let Some(start) = start_date {
|
||||
conditions.push("created_at >= ?");
|
||||
params_vec.push(Box::new(start));
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
conditions.push("created_at <= ?");
|
||||
params_vec.push(Box::new(end));
|
||||
}
|
||||
if let Some(at) = app_type {
|
||||
conditions.push("app_type = ?");
|
||||
params_vec.push(Box::new(at.to_string()));
|
||||
}
|
||||
|
||||
(format!("WHERE {}", conditions.join(" AND ")), params)
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
(String::new(), Vec::new())
|
||||
format!("WHERE {}", conditions.join(" AND "))
|
||||
};
|
||||
|
||||
// Build rollup WHERE clause using date strings (use ? for sequential binding)
|
||||
let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
// Build rollup WHERE clause using date strings
|
||||
let mut rollup_conditions: Vec<String> = Vec::new();
|
||||
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(start) = start_date {
|
||||
conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
params.push(start);
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
params.push(end);
|
||||
}
|
||||
if let Some(start) = start_date {
|
||||
rollup_conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
rollup_params.push(Box::new(start));
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
rollup_conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
rollup_params.push(Box::new(end));
|
||||
}
|
||||
if let Some(at) = app_type {
|
||||
rollup_conditions.push("app_type = ?".to_string());
|
||||
rollup_params.push(Box::new(at.to_string()));
|
||||
}
|
||||
|
||||
(format!("WHERE {}", conditions.join(" AND ")), params)
|
||||
let rollup_where = if rollup_conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
(String::new(), Vec::new())
|
||||
format!("WHERE {}", rollup_conditions.join(" AND "))
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
@@ -192,10 +217,11 @@ impl Database {
|
||||
);
|
||||
|
||||
// Combine params: detail params first, then rollup params
|
||||
let mut all_params: Vec<i64> = params_vec;
|
||||
let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = params_vec;
|
||||
all_params.extend(rollup_params);
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| {
|
||||
let result = conn.query_row(&sql, param_refs.as_slice(), |row| {
|
||||
let total_requests: i64 = row.get(0)?;
|
||||
let total_cost: f64 = row.get(1)?;
|
||||
let total_input_tokens: i64 = row.get(2)?;
|
||||
@@ -229,6 +255,7 @@ impl Database {
|
||||
&self,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -260,9 +287,15 @@ impl Database {
|
||||
bucket_count = 1;
|
||||
}
|
||||
|
||||
let app_type_filter = if app_type.is_some() {
|
||||
"AND app_type = ?4"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
// Query detail logs
|
||||
let sql = "
|
||||
SELECT
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
@@ -272,12 +305,13 @@ impl Database {
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||
FROM proxy_request_logs
|
||||
WHERE created_at >= ?1 AND created_at <= ?2
|
||||
WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter}
|
||||
GROUP BY bucket_idx
|
||||
ORDER BY bucket_idx ASC";
|
||||
ORDER BY bucket_idx ASC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let row_mapper = |row: &rusqlite::Row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
DailyStats {
|
||||
@@ -291,24 +325,33 @@ impl Database {
|
||||
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
};
|
||||
|
||||
let mut map: HashMap<i64, DailyStats> = HashMap::new();
|
||||
for row in rows {
|
||||
let (mut bucket_idx, stat) = row?;
|
||||
if bucket_idx < 0 {
|
||||
continue;
|
||||
|
||||
// Collect rows into map (need to handle both param variants)
|
||||
{
|
||||
let rows = if let Some(at) = app_type {
|
||||
stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)?
|
||||
} else {
|
||||
stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)?
|
||||
};
|
||||
for row in rows {
|
||||
let (mut bucket_idx, stat) = row?;
|
||||
if bucket_idx < 0 {
|
||||
continue;
|
||||
}
|
||||
if bucket_idx >= bucket_count {
|
||||
bucket_idx = bucket_count - 1;
|
||||
}
|
||||
map.insert(bucket_idx, stat);
|
||||
}
|
||||
if bucket_idx >= bucket_count {
|
||||
bucket_idx = bucket_count - 1;
|
||||
}
|
||||
map.insert(bucket_idx, stat);
|
||||
}
|
||||
|
||||
// Also query rollup data (daily granularity, only useful for daily buckets)
|
||||
if bucket_seconds >= 86400 {
|
||||
let rollup_sql = "
|
||||
SELECT
|
||||
let rollup_sql = format!(
|
||||
"SELECT
|
||||
CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COALESCE(SUM(request_count), 0),
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0),
|
||||
@@ -318,12 +361,12 @@ impl Database {
|
||||
COALESCE(SUM(cache_creation_tokens), 0),
|
||||
COALESCE(SUM(cache_read_tokens), 0)
|
||||
FROM usage_daily_rollups
|
||||
WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime')
|
||||
WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter}
|
||||
GROUP BY bucket_idx
|
||||
ORDER BY bucket_idx ASC";
|
||||
ORDER BY bucket_idx ASC"
|
||||
);
|
||||
|
||||
let mut rstmt = conn.prepare(rollup_sql)?;
|
||||
let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
|
||||
let rollup_row_mapper = |row: &rusqlite::Row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
(
|
||||
@@ -336,7 +379,17 @@ impl Database {
|
||||
row.get::<_, i64>(7)? as u64,
|
||||
),
|
||||
))
|
||||
})?;
|
||||
};
|
||||
|
||||
let mut rstmt = conn.prepare(&rollup_sql)?;
|
||||
let rrows = if let Some(at) = app_type {
|
||||
rstmt.query_map(
|
||||
params![start_ts, end_ts, bucket_seconds, at],
|
||||
rollup_row_mapper,
|
||||
)?
|
||||
} else {
|
||||
rstmt.query_map(params![start_ts, end_ts, bucket_seconds], rollup_row_mapper)?
|
||||
};
|
||||
|
||||
for row in rrows {
|
||||
let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?;
|
||||
@@ -398,11 +451,23 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
|
||||
pub fn get_provider_stats(
|
||||
&self,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<ProviderStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let (detail_where, rollup_where) = if app_type.is_some() {
|
||||
("WHERE l.app_type = ?1", "WHERE r.app_type = ?2")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
|
||||
// UNION detail logs + rollup data, then aggregate
|
||||
let sql = "SELECT
|
||||
let detail_pname = provider_name_coalesce("l", "p");
|
||||
let rollup_pname = provider_name_coalesce("r", "p2");
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
provider_id, app_type, provider_name,
|
||||
SUM(request_count) as request_count,
|
||||
SUM(total_tokens) as total_tokens,
|
||||
@@ -413,7 +478,7 @@ impl Database {
|
||||
ELSE 0 END as avg_latency
|
||||
FROM (
|
||||
SELECT l.provider_id, l.app_type,
|
||||
p.name as provider_name,
|
||||
{detail_pname} as provider_name,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
|
||||
@@ -421,10 +486,11 @@ impl Database {
|
||||
COALESCE(SUM(l.latency_ms), 0) as latency_sum
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
{detail_where}
|
||||
GROUP BY l.provider_id, l.app_type
|
||||
UNION ALL
|
||||
SELECT r.provider_id, r.app_type,
|
||||
p2.name as provider_name,
|
||||
{rollup_pname} as provider_name,
|
||||
COALESCE(SUM(r.request_count), 0),
|
||||
COALESCE(SUM(r.input_tokens + r.output_tokens), 0),
|
||||
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
|
||||
@@ -432,13 +498,15 @@ impl Database {
|
||||
COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)
|
||||
FROM usage_daily_rollups r
|
||||
LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type
|
||||
{rollup_where}
|
||||
GROUP BY r.provider_id, r.app_type
|
||||
)
|
||||
GROUP BY provider_id, app_type
|
||||
ORDER BY total_cost DESC";
|
||||
ORDER BY total_cost DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let row_mapper = |row: &rusqlite::Row| {
|
||||
let request_count: i64 = row.get(3)?;
|
||||
let success_count: i64 = row.get(6)?;
|
||||
let success_rate = if request_count > 0 {
|
||||
@@ -449,16 +517,20 @@ impl Database {
|
||||
|
||||
Ok(ProviderStats {
|
||||
provider_id: row.get(0)?,
|
||||
provider_name: row
|
||||
.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
provider_name: row.get(2)?,
|
||||
request_count: request_count as u64,
|
||||
total_tokens: row.get::<_, i64>(4)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(5)?),
|
||||
success_rate,
|
||||
avg_latency_ms: row.get::<_, f64>(7)? as u64,
|
||||
})
|
||||
})?;
|
||||
};
|
||||
|
||||
let rows = if let Some(at) = app_type {
|
||||
stmt.query_map(params![at, at], row_mapper)?
|
||||
} else {
|
||||
stmt.query_map([], row_mapper)?
|
||||
};
|
||||
|
||||
let mut stats = Vec::new();
|
||||
for row in rows {
|
||||
@@ -469,11 +541,18 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
|
||||
pub fn get_model_stats(&self, app_type: Option<&str>) -> Result<Vec<ModelStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let (detail_where, rollup_where) = if app_type.is_some() {
|
||||
("WHERE app_type = ?1", "WHERE app_type = ?2")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
|
||||
// UNION detail logs + rollup data
|
||||
let sql = "SELECT
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
model,
|
||||
SUM(request_count) as request_count,
|
||||
SUM(total_tokens) as total_tokens,
|
||||
@@ -484,6 +563,7 @@ impl Database {
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs
|
||||
{detail_where}
|
||||
GROUP BY model
|
||||
UNION ALL
|
||||
SELECT model,
|
||||
@@ -491,13 +571,15 @@ impl Database {
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0),
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM usage_daily_rollups
|
||||
{rollup_where}
|
||||
GROUP BY model
|
||||
)
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC";
|
||||
ORDER BY total_cost DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let row_mapper = |row: &rusqlite::Row| {
|
||||
let request_count: i64 = row.get(1)?;
|
||||
let total_cost: f64 = row.get(3)?;
|
||||
let avg_cost = if request_count > 0 {
|
||||
@@ -513,7 +595,13 @@ impl Database {
|
||||
total_cost: format!("{total_cost:.6}"),
|
||||
avg_cost_per_request: format!("{avg_cost:.6}"),
|
||||
})
|
||||
})?;
|
||||
};
|
||||
|
||||
let rows = if let Some(at) = app_type {
|
||||
stmt.query_map(params![at, at], row_mapper)?
|
||||
} else {
|
||||
stmt.query_map([], row_mapper)?
|
||||
};
|
||||
|
||||
let mut stats = Vec::new();
|
||||
for row in rows {
|
||||
@@ -582,13 +670,14 @@ impl Database {
|
||||
params.push(Box::new(page_size as i64));
|
||||
params.push(Box::new(offset as i64));
|
||||
|
||||
let logs_pname = provider_name_coalesce("l", "p");
|
||||
let sql = format!(
|
||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||
"SELECT l.request_id, l.provider_id, {logs_pname} as provider_name, l.app_type, l.model,
|
||||
l.request_model, l.cost_multiplier,
|
||||
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
|
||||
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
|
||||
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
|
||||
l.status_code, l.error_message, l.created_at
|
||||
l.status_code, l.error_message, l.created_at, l.data_source
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
{where_clause}
|
||||
@@ -625,6 +714,7 @@ impl Database {
|
||||
status_code: row.get::<_, i64>(20)? as u16,
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
data_source: row.get(23)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -658,45 +748,48 @@ impl Database {
|
||||
) -> Result<Option<RequestLogDetail>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let result = conn.query_row(
|
||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||
let detail_pname = provider_name_coalesce("l", "p");
|
||||
let detail_sql = format!(
|
||||
"SELECT l.request_id, l.provider_id, {detail_pname} as provider_name, l.app_type, l.model,
|
||||
l.request_model, l.cost_multiplier,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
is_streaming, latency_ms, first_token_ms, duration_ms,
|
||||
status_code, error_message, created_at
|
||||
status_code, error_message, created_at, l.data_source
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
WHERE l.request_id = ?",
|
||||
[request_id],
|
||||
|row| {
|
||||
Ok(RequestLogDetail {
|
||||
request_id: row.get(0)?,
|
||||
provider_id: row.get(1)?,
|
||||
provider_name: row.get(2)?,
|
||||
app_type: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
request_model: row.get(5)?,
|
||||
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()),
|
||||
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||
input_cost_usd: row.get(11)?,
|
||||
output_cost_usd: row.get(12)?,
|
||||
cache_read_cost_usd: row.get(13)?,
|
||||
cache_creation_cost_usd: row.get(14)?,
|
||||
total_cost_usd: row.get(15)?,
|
||||
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(20)? as u16,
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
})
|
||||
},
|
||||
WHERE l.request_id = ?"
|
||||
);
|
||||
let result = conn.query_row(&detail_sql, [request_id], |row| {
|
||||
Ok(RequestLogDetail {
|
||||
request_id: row.get(0)?,
|
||||
provider_id: row.get(1)?,
|
||||
provider_name: row.get(2)?,
|
||||
app_type: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
request_model: row.get(5)?,
|
||||
cost_multiplier: row
|
||||
.get::<_, Option<String>>(6)?
|
||||
.unwrap_or_else(|| "1".to_string()),
|
||||
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||
input_cost_usd: row.get(11)?,
|
||||
output_cost_usd: row.get(12)?,
|
||||
cache_read_cost_usd: row.get(13)?,
|
||||
cache_creation_cost_usd: row.get(14)?,
|
||||
total_cost_usd: row.get(15)?,
|
||||
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(20)? as u16,
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
data_source: row.get(23)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(mut detail) => {
|
||||
@@ -1040,7 +1133,7 @@ mod tests {
|
||||
)?;
|
||||
}
|
||||
|
||||
let summary = db.get_usage_summary(None, None)?;
|
||||
let summary = db.get_usage_summary(None, None, None)?;
|
||||
assert_eq!(summary.total_requests, 2);
|
||||
assert_eq!(summary.success_rate, 100.0);
|
||||
|
||||
@@ -1075,7 +1168,7 @@ mod tests {
|
||||
)?;
|
||||
}
|
||||
|
||||
let stats = db.get_model_stats()?;
|
||||
let stats = db.get_model_stats(None)?;
|
||||
assert_eq!(stats.len(), 1);
|
||||
assert_eq!(stats[0].model, "claude-3-sonnet");
|
||||
assert_eq!(stats[0].request_count, 1);
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::SyncMethod;
|
||||
use crate::services::skill::{SkillStorageLocation, SyncMethod};
|
||||
|
||||
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -205,6 +205,12 @@ pub struct AppSettings {
|
||||
/// User has confirmed the failover toggle first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failover_confirmed: Option<bool>,
|
||||
/// User has confirmed the first-run welcome notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_run_notice_confirmed: Option<bool>,
|
||||
/// User has confirmed the common config first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub common_config_confirmed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
@@ -245,6 +251,9 @@ pub struct AppSettings {
|
||||
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
#[serde(default)]
|
||||
pub skill_sync_method: SyncMethod,
|
||||
/// Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/)
|
||||
#[serde(default)]
|
||||
pub skill_storage_location: SkillStorageLocation,
|
||||
|
||||
// ===== WebDAV 同步设置 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -294,6 +303,8 @@ impl Default for AppSettings {
|
||||
stream_check_confirmed: None,
|
||||
enable_failover_toggle: false,
|
||||
failover_confirmed: None,
|
||||
first_run_notice_confirmed: None,
|
||||
common_config_confirmed: None,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
claude_config_dir: None,
|
||||
@@ -307,6 +318,7 @@ impl Default for AppSettings {
|
||||
current_provider_opencode: None,
|
||||
current_provider_openclaw: None,
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
skill_storage_location: SkillStorageLocation::default(),
|
||||
webdav_sync: None,
|
||||
webdav_backup: None,
|
||||
backup_interval_hours: None,
|
||||
@@ -642,6 +654,26 @@ pub fn get_skill_sync_method() -> SyncMethod {
|
||||
.skill_sync_method
|
||||
}
|
||||
|
||||
// ===== Skill 存储位置管理函数 =====
|
||||
|
||||
/// 获取 Skill 存储位置配置
|
||||
pub fn get_skill_storage_location() -> SkillStorageLocation {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.skill_storage_location
|
||||
}
|
||||
|
||||
/// 设置 Skill 存储位置
|
||||
pub fn set_skill_storage_location(location: SkillStorageLocation) -> Result<(), AppError> {
|
||||
mutate_settings(|s| {
|
||||
s.skill_storage_location = location;
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 备份策略管理函数 =====
|
||||
|
||||
/// Get the effective auto-backup interval in hours (default 24)
|
||||
|
||||
@@ -424,6 +424,10 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
crate::linux_fix::nudge_main_window(window.clone());
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(app, true);
|
||||
|
||||
Reference in New Issue
Block a user