mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
Merge branch 'main' into codex/issue-1888-interception-matrix
This commit is contained in:
Generated
+1
-1
@@ -735,7 +735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.12.3"
|
||||
version = "3.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.12.3"
|
||||
version = "3.13.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"updater:default",
|
||||
"core:window:allow-set-skip-taskbar",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-decorations",
|
||||
"process:allow-restart",
|
||||
"dialog:default"
|
||||
]
|
||||
|
||||
@@ -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 管理)
|
||||
|
||||
@@ -70,6 +70,7 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
+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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::services::subscription::SubscriptionQuota;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
|
||||
}
|
||||
@@ -948,6 +948,7 @@ exec bash --norc --noprofile
|
||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||||
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
|
||||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod auth;
|
||||
mod balance;
|
||||
mod codex_oauth;
|
||||
mod coding_plan;
|
||||
mod config;
|
||||
mod copilot;
|
||||
mod deeplink;
|
||||
@@ -10,6 +13,7 @@ mod global_proxy;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod model_fetch;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod plugin;
|
||||
@@ -20,6 +24,7 @@ mod session_manager;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod subscription;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
@@ -28,6 +33,9 @@ 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::*;
|
||||
pub use deeplink::*;
|
||||
@@ -37,6 +45,7 @@ pub use global_proxy::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use model_fetch::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub use plugin::*;
|
||||
@@ -47,6 +56,7 @@ pub use session_manager::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use subscription::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! 模型列表获取命令
|
||||
//!
|
||||
//! 提供 Tauri 命令,供前端在供应商表单中获取可用模型列表。
|
||||
|
||||
use crate::services::model_fetch::{self, FetchedModel};
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn fetch_models_for_config(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
is_full_url: Option<bool>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await
|
||||
}
|
||||
@@ -13,6 +13,8 @@ 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";
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -158,29 +160,25 @@ pub async fn queryProviderUsage(
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 检查是否为 GitHub Copilot 模板类型,并解析绑定账号
|
||||
let (is_copilot_template, copilot_account_id) = {
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
// 从数据库读取供应商信息,检查特殊模板类型
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
let provider = providers.get(&providerId);
|
||||
let usage_script = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref());
|
||||
let template_type = usage_script
|
||||
.and_then(|s| s.template_type.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
let provider = providers.get(&providerId);
|
||||
let is_copilot = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.and_then(|s| s.template_type.as_ref())
|
||||
.map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT)
|
||||
.unwrap_or(false);
|
||||
let account_id = provider
|
||||
// ── GitHub Copilot 专用路径 ──
|
||||
if template_type == TEMPLATE_TYPE_GITHUB_COPILOT {
|
||||
let copilot_account_id = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
|
||||
|
||||
(is_copilot, account_id)
|
||||
};
|
||||
|
||||
if is_copilot_template {
|
||||
// 使用 Copilot 专用 API
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let usage = match copilot_account_id.as_deref() {
|
||||
Some(account_id) => auth_manager
|
||||
@@ -211,6 +209,91 @@ pub async fn queryProviderUsage(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Coding Plan 专用路径 ──
|
||||
if template_type == TEMPLATE_TYPE_TOKEN_PLAN {
|
||||
// 从供应商配置中提取 API Key 和 Base URL
|
||||
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("");
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
|
||||
// 将 SubscriptionQuota 转换为 UsageResult
|
||||
if !quota.success {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: quota.error,
|
||||
});
|
||||
}
|
||||
|
||||
let data: Vec<crate::provider::UsageData> = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.map(|tier| {
|
||||
let total = 100.0;
|
||||
let used = tier.utilization;
|
||||
let remaining = total - used;
|
||||
crate::provider::UsageData {
|
||||
plan_name: Some(tier.name.clone()),
|
||||
remaining: Some(remaining),
|
||||
total: Some(total),
|
||||
used: Some(used),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: tier.resets_at.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: true,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 官方余额查询路径 ──
|
||||
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
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
@@ -253,6 +253,19 @@ pub async fn switch_proxy_provider(
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
// Block official providers during proxy takeover
|
||||
let provider = state
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, &app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
.proxy_service
|
||||
.switch_proxy_target(&app_type, &provider_id)
|
||||
|
||||
@@ -247,6 +247,30 @@ pub async fn set_optimizer_config(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取 Copilot 优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_copilot_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::CopilotOptimizerConfig, String> {
|
||||
state
|
||||
.db
|
||||
.get_copilot_optimizer_config()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置 Copilot 优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_copilot_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::CopilotOptimizerConfig,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.set_copilot_optimizer_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取日志配置
|
||||
#[tauri::command]
|
||||
pub async fn get_log_config(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -116,15 +116,24 @@ pub async fn stream_check_all_providers(
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
.unwrap_or_else(|e| {
|
||||
let (http_status, message) = match &e {
|
||||
crate::error::AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
StreamCheckService::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message,
|
||||
response_time_ms: None,
|
||||
http_status,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
}
|
||||
});
|
||||
|
||||
let _ = state
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::services::subscription::SubscriptionQuota;
|
||||
|
||||
/// 查询官方订阅额度
|
||||
///
|
||||
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
|
||||
/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。
|
||||
#[tauri::command]
|
||||
pub async fn get_subscription_quota(tool: String) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::subscription::get_subscription_quota(&tool).await
|
||||
}
|
||||
@@ -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);
|
||||
@@ -270,6 +282,31 @@ impl Database {
|
||||
self.set_setting("optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- Copilot 优化器配置 ---
|
||||
|
||||
/// 获取 Copilot 优化器配置
|
||||
///
|
||||
/// 返回配置,如果不存在则返回默认值(默认开启)
|
||||
pub fn get_copilot_optimizer_config(
|
||||
&self,
|
||||
) -> Result<crate::proxy::types::CopilotOptimizerConfig, AppError> {
|
||||
match self.get_setting("copilot_optimizer_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析 Copilot 优化器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::CopilotOptimizerConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 Copilot 优化器配置
|
||||
pub fn set_copilot_optimizer_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::CopilotOptimizerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化 Copilot 优化器配置失败: {e}")))?;
|
||||
self.set_setting("copilot_optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- 日志配置 ---
|
||||
|
||||
/// 获取日志配置
|
||||
|
||||
@@ -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 = 9;
|
||||
|
||||
/// 安全地序列化 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,21 @@ 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)?;
|
||||
}
|
||||
8 => {
|
||||
log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
|
||||
Self::migrate_v8_to_v9(conn)?;
|
||||
Self::set_user_version(conn, 9)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1045,6 +1075,99 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v6 -> v7: Skills 更新检测支持(content_hash + updated_at)
|
||||
fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "skills")? {
|
||||
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
|
||||
if Self::table_exists(conn, "model_pricing")? {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// v8 → v9: 全面补充模型定价(清空 + 重新 seed)
|
||||
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
|
||||
conn.execute("DELETE FROM model_pricing", [])
|
||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||
Self::seed_model_pricing(conn)?;
|
||||
log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -1134,6 +1257,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 +1421,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 +1479,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,85 +1505,317 @@ 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",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-pro",
|
||||
"Doubao Seed 2.0 Pro",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-code",
|
||||
"Doubao Seed 2.0 Code",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-lite",
|
||||
"Doubao Seed 2.0 Lite",
|
||||
"0.25",
|
||||
"2",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-mini",
|
||||
"Doubao Seed 2.0 Mini",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0",
|
||||
"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.27",
|
||||
"1.10",
|
||||
"0.07",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.55",
|
||||
"2.19",
|
||||
"0.14",
|
||||
"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", "2.50", "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"),
|
||||
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
|
||||
(
|
||||
"minimax-m2.5-lightning",
|
||||
"MiniMax M2.5 Lightning",
|
||||
"0.30",
|
||||
"2.40",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"minimax-m2.7",
|
||||
"MiniMax M2.7",
|
||||
"0.30",
|
||||
"1.20",
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
(
|
||||
"minimax-m2.7-highspeed",
|
||||
"MiniMax M2.7 Highspeed",
|
||||
"0.60",
|
||||
"2.40",
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
// 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"),
|
||||
// Mimo (小米)
|
||||
("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "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"),
|
||||
("glm-5", "GLM-5", "0.72", "2.30", "0", "0"),
|
||||
("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"),
|
||||
// MiMo (小米)
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
"MiMo V2 Flash",
|
||||
"0.09",
|
||||
"0.29",
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
|
||||
// Qwen 系列 (阿里巴巴)
|
||||
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
|
||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||
(
|
||||
"qwen3-235b-a22b",
|
||||
"Qwen3 235B-A22B",
|
||||
"0.70",
|
||||
"8.40",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-plus",
|
||||
"Qwen3 Coder Plus",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-flash",
|
||||
"Qwen3 Coder Flash",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-next",
|
||||
"Qwen3 Coder Next",
|
||||
"0.12",
|
||||
"0.75",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
|
||||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||
// Grok 系列 (xAI)
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
"Grok 4.20 Reasoning",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"Grok 4.20",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4-1-fast-reasoning",
|
||||
"Grok 4.1 Fast Reasoning",
|
||||
"0.20",
|
||||
"0.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4-1-fast-non-reasoning",
|
||||
"Grok 4.1 Fast",
|
||||
"0.20",
|
||||
"0.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
("grok-4", "Grok 4", "3", "15", "0.75", "0"),
|
||||
(
|
||||
"grok-code-fast-1",
|
||||
"Grok Code Fast",
|
||||
"0.20",
|
||||
"1.50",
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
||||
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
||||
// Mistral 系列
|
||||
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
||||
(
|
||||
"devstral-small-1.1",
|
||||
"Devstral Small 1.1",
|
||||
"0.07",
|
||||
"0.28",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
|
||||
(
|
||||
"devstral-medium",
|
||||
"Devstral Medium",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-large-3-2512",
|
||||
"Mistral Large 3",
|
||||
"0.50",
|
||||
"1.50",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-medium-3.1",
|
||||
"Mistral Medium 3.1",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-small-3.2-24b",
|
||||
"Mistral Small 3.2",
|
||||
"0.075",
|
||||
"0.20",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
|
||||
// Cohere 系列
|
||||
("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
|
||||
(
|
||||
"command-r-plus",
|
||||
"Cohere Command R+",
|
||||
"2.50",
|
||||
"10",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
|
||||
// OpenAI 补充
|
||||
("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
|
||||
("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
|
||||
("o1", "OpenAI o1", "15", "60", "7.50", "0"),
|
||||
("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
|
||||
("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
|
||||
("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
|
||||
("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
|
||||
];
|
||||
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
conn.execute(
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input,
|
||||
output,
|
||||
cache_read,
|
||||
cache_creation
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
stmt.execute(rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input,
|
||||
output,
|
||||
cache_read,
|
||||
cache_creation
|
||||
])
|
||||
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
coding_plan_provider: None,
|
||||
};
|
||||
|
||||
Ok(Some(ProviderMeta {
|
||||
|
||||
@@ -44,6 +44,8 @@ pub enum AppError {
|
||||
McpValidation(String),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("HTTP {status}: {body}")]
|
||||
HttpStatus { status: u16, body: String },
|
||||
#[error("{zh} ({en})")]
|
||||
Localized {
|
||||
key: &'static str,
|
||||
|
||||
@@ -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 初始化失败导致白屏
|
||||
@@ -816,6 +971,10 @@ pub fn run() {
|
||||
// 静默启动:根据设置决定是否显示主窗口
|
||||
let settings = crate::settings::get_settings();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
// 在窗口首次显示前同步装饰状态,避免前端加载后再切换导致标题栏闪烁
|
||||
// 仅 Linux 生效:解决 Wayland 下系统窗口按钮不可用的问题
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = window.set_decorations(!settings.use_app_window_controls);
|
||||
if settings.silent_startup {
|
||||
// 静默启动模式:保持窗口隐藏
|
||||
let _ = window.hide();
|
||||
@@ -828,6 +987,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,6 +1034,8 @@ pub fn run() {
|
||||
commands::set_rectifier_config,
|
||||
commands::get_optimizer_config,
|
||||
commands::set_optimizer_config,
|
||||
commands::get_copilot_optimizer_config,
|
||||
commands::set_copilot_optimizer_config,
|
||||
commands::get_log_config,
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
@@ -888,6 +1057,11 @@ pub fn run() {
|
||||
// usage query
|
||||
commands::queryProviderUsage,
|
||||
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,
|
||||
@@ -906,6 +1080,8 @@ pub fn run() {
|
||||
commands::enable_prompt,
|
||||
commands::import_prompt_from_file,
|
||||
commands::get_current_prompt_file_content,
|
||||
// model list fetch (OpenAI-compatible /v1/models)
|
||||
commands::fetch_models_for_config,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
commands::test_api_endpoints,
|
||||
commands::get_custom_endpoints,
|
||||
@@ -955,6 +1131,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,
|
||||
@@ -1013,6 +1193,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();
|
||||
|
||||
@@ -61,7 +61,12 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||
json5::from_str(&content).map_err(|e| {
|
||||
AppError::Config(format!(
|
||||
"Failed to parse OpenCode config: {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
|
||||
@@ -106,6 +106,10 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "autoQueryInterval")]
|
||||
pub auto_query_interval: Option<u64>,
|
||||
/// Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "codingPlanProvider")]
|
||||
pub coding_plan_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
@@ -168,29 +172,6 @@ pub struct ProviderTestConfig {
|
||||
pub max_retries: Option<u32>,
|
||||
}
|
||||
|
||||
/// 供应商单独的代理配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderProxyConfig {
|
||||
/// 是否启用单独配置(false 时使用全局/系统代理)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// 代理类型:http, https, socks5
|
||||
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_type: Option<String>,
|
||||
/// 代理主机
|
||||
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_host: Option<String>,
|
||||
/// 代理端口
|
||||
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_port: Option<u16>,
|
||||
/// 代理用户名(可选)
|
||||
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_username: Option<String>,
|
||||
/// 代理密码(可选)
|
||||
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 认证绑定来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -258,9 +239,6 @@ pub struct ProviderMeta {
|
||||
/// 供应商单独的模型测试配置
|
||||
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub test_config: Option<ProviderTestConfig>,
|
||||
/// 供应商单独的代理配置
|
||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
@@ -278,9 +256,9 @@ pub struct ProviderMeta {
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
/// Prompt cache key for OpenAI Responses-compatible endpoints.
|
||||
/// When set, injected into converted Responses requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during Claude -> Responses conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,11 @@ use super::{
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
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;
|
||||
@@ -52,6 +53,8 @@ pub struct RequestForwarder {
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
@@ -70,6 +73,7 @@ impl RequestForwarder {
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
router,
|
||||
@@ -80,6 +84,7 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
@@ -760,7 +765,7 @@ impl RequestForwarder {
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
@@ -771,6 +776,102 @@ impl RequestForwarder {
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
|
||||
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
|
||||
//
|
||||
// 执行顺序(与 copilot-api 对齐):
|
||||
// 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user)
|
||||
// 2. 再清洗孤立 tool_result(防止上游 API 报错)
|
||||
// 3. 再合并 tool_result + text(减少 premium 计费)
|
||||
let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled {
|
||||
// 1. 在原始 body 上分类 — 必须在清洗/合并之前执行
|
||||
// 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent
|
||||
let has_anthropic_beta = headers.contains_key("anthropic-beta");
|
||||
let classification = super::copilot_optimizer::classify_request(
|
||||
&mapped_body,
|
||||
has_anthropic_beta,
|
||||
self.copilot_optimizer_config.compact_detection,
|
||||
self.copilot_optimizer_config.subagent_detection,
|
||||
);
|
||||
|
||||
log::debug!(
|
||||
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}",
|
||||
classification.initiator,
|
||||
classification.is_warmup,
|
||||
classification.is_compact,
|
||||
classification.is_subagent
|
||||
);
|
||||
|
||||
// 2. 孤立 tool_result 清理 — 分类完成后再清洗
|
||||
// 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费
|
||||
mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body);
|
||||
|
||||
// 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)]
|
||||
if self.copilot_optimizer_config.tool_result_merging {
|
||||
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
|
||||
}
|
||||
|
||||
// 4. Warmup 小模型降级
|
||||
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
|
||||
log::info!(
|
||||
"[Copilot] Warmup 请求降级到模型: {}",
|
||||
self.copilot_optimizer_config.warmup_model
|
||||
);
|
||||
mapped_body["model"] =
|
||||
serde_json::json!(&self.copilot_optimizer_config.warmup_model);
|
||||
}
|
||||
|
||||
// 预计算确定性 Request ID(在 body 被 move 之前)
|
||||
// Session 提取优先级(与 session.rs extract_from_metadata 对齐):
|
||||
// 1. metadata.user_id 中的 _session_ 后缀
|
||||
// 2. metadata.session_id(直接字段)
|
||||
// 3. raw metadata.user_id(整串 fallback)
|
||||
// 4. x-session-id header
|
||||
let metadata = body.get("metadata");
|
||||
let session_id = metadata
|
||||
.and_then(|m| m.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(super::session::parse_session_from_user_id)
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|m| m.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-session-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let det_request_id = if self.copilot_optimizer_config.deterministic_request_id {
|
||||
Some(super::copilot_optimizer::deterministic_request_id(
|
||||
&mapped_body,
|
||||
&session_id,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 从 session ID 派生稳定的 interaction ID(同一主对话共享)
|
||||
let interaction_id =
|
||||
super::copilot_optimizer::deterministic_interaction_id(&session_id);
|
||||
|
||||
Some((classification, det_request_id, interaction_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// GitHub Copilot 动态 endpoint 路由
|
||||
// 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint)
|
||||
if is_copilot && !is_full_url {
|
||||
@@ -857,8 +958,11 @@ 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 auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
if auth.strategy == AuthStrategy::GitHubCopilot {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
@@ -909,11 +1013,109 @@ 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, ref interaction_id)) =
|
||||
copilot_optimization
|
||||
{
|
||||
for (name, value) in auth_headers.iter_mut() {
|
||||
match name.as_str() {
|
||||
"x-initiator" if self.copilot_optimizer_config.request_classification => {
|
||||
*value = http::HeaderValue::from_static(classification.initiator);
|
||||
}
|
||||
"x-interaction-type" if classification.is_subagent => {
|
||||
// 子代理请求:conversation-subagent 不计 premium interaction
|
||||
*value = http::HeaderValue::from_static("conversation-subagent");
|
||||
}
|
||||
"x-request-id" | "x-agent-task-id" => {
|
||||
if let Some(ref det_id) = det_request_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(det_id) {
|
||||
*value = hv;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中)
|
||||
if let Some(ref iid) = interaction_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(iid) {
|
||||
auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv));
|
||||
}
|
||||
}
|
||||
|
||||
if classification.is_subagent {
|
||||
log::info!(
|
||||
"[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
|
||||
let copilot_fingerprint_headers: &[&str] = if is_copilot {
|
||||
&[
|
||||
@@ -926,6 +1128,7 @@ impl RequestForwarder {
|
||||
// 新增 headers
|
||||
"x-initiator",
|
||||
"x-interaction-type",
|
||||
"x-interaction-id",
|
||||
"x-vscode-user-agent-library-version",
|
||||
"x-request-id",
|
||||
"x-agent-task-id",
|
||||
@@ -1155,12 +1358,8 @@ impl RequestForwarder {
|
||||
self.non_streaming_timeout
|
||||
};
|
||||
|
||||
// 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let upstream_proxy_url: Option<String> = proxy_config
|
||||
.filter(|c| c.enabled)
|
||||
.and_then(super::http_client::build_proxy_url_from_config)
|
||||
.or_else(super::http_client::get_current_proxy_url);
|
||||
// 获取全局代理 URL
|
||||
let upstream_proxy_url: Option<String> = super::http_client::get_current_proxy_url();
|
||||
|
||||
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
|
||||
let is_socks_proxy = upstream_proxy_url
|
||||
@@ -1176,7 +1375,7 @@ impl RequestForwarder {
|
||||
let response = if is_socks_proxy {
|
||||
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
|
||||
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
|
||||
let client = super::http_client::get_for_provider(proxy_config);
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url);
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, OptimizerConfig, RectifierConfig},
|
||||
types::{AppProxyConfig, CopilotOptimizerConfig, OptimizerConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -61,6 +61,8 @@ pub struct RequestContext {
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
pub copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -96,6 +98,7 @@ impl RequestContext {
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();
|
||||
let copilot_optimizer_config = state.db.get_copilot_optimizer_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
@@ -160,6 +163,7 @@ impl RequestContext {
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +225,7 @@ impl RequestContext {
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
self.copilot_optimizer_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let request_id = usage.dedup_request_id();
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
request_id,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||
|
||||
use crate::provider::ProviderProxyConfig;
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::Client;
|
||||
use std::env;
|
||||
@@ -334,103 +333,6 @@ pub fn mask_url(url: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据供应商单独代理配置构建代理 URL
|
||||
///
|
||||
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||
pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||
let host = config.proxy_host.as_deref()?;
|
||||
let port = config.proxy_port?;
|
||||
|
||||
// 构建带认证的代理 URL
|
||||
if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
|
||||
if !username.is_empty() && !password.is_empty() {
|
||||
return Some(format!(
|
||||
"{proxy_type}://{username}:{password}@{host}:{port}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!("{proxy_type}://{host}:{port}"))
|
||||
}
|
||||
|
||||
/// 根据供应商单独代理配置构建 HTTP 客户端
|
||||
///
|
||||
/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
|
||||
/// 否则返回 None,调用方应使用全局客户端。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_config` - 供应商的代理配置
|
||||
///
|
||||
/// # Returns
|
||||
/// 如果配置有效则返回 Some(Client),否则返回 None
|
||||
pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {
|
||||
let config = proxy_config.filter(|c| c.enabled)?;
|
||||
|
||||
let proxy_url = build_proxy_url_from_config(config)?;
|
||||
|
||||
log::debug!(
|
||||
"[ProviderProxy] Building client with proxy: {}",
|
||||
mask_url(&proxy_url)
|
||||
);
|
||||
|
||||
// 构建带代理的客户端
|
||||
let proxy = match reqwest::Proxy::all(&proxy_url) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[ProviderProxy] Failed to create proxy from '{}': {}",
|
||||
mask_url(&proxy_url),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
{
|
||||
Ok(client) => {
|
||||
log::info!(
|
||||
"[ProviderProxy] Client built with proxy: {}",
|
||||
mask_url(&proxy_url)
|
||||
);
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[ProviderProxy] Failed to build client: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取供应商专用的 HTTP 客户端
|
||||
///
|
||||
/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_config` - 供应商的代理配置
|
||||
///
|
||||
/// # Returns
|
||||
/// 返回适合该供应商的 HTTP 客户端
|
||||
pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
|
||||
// 优先使用供应商单独代理
|
||||
if let Some(client) = build_client_for_provider(proxy_config) {
|
||||
return client;
|
||||
}
|
||||
|
||||
// 回退到全局客户端
|
||||
get()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub mod copilot_optimizer;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
pub(crate) mod failover_switch;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -22,6 +22,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() {
|
||||
@@ -74,17 +81,64 @@ pub fn transform_claude_request_for_api_format(
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
let cache_key = provider
|
||||
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
|
||||
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
|
||||
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|u| u.contains("githubcopilot.com"));
|
||||
let session_cache_key: Option<String> = if is_copilot {
|
||||
let metadata = body.get("metadata");
|
||||
// Session 提取优先级(与 forwarder 和 session.rs 统一):
|
||||
// 1. metadata.user_id 中的 _session_ 后缀
|
||||
// 2. metadata.session_id(直接字段)
|
||||
metadata
|
||||
.and_then(|m| m.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(super::super::session::parse_session_from_user_id)
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cache_key = session_cache_key
|
||||
.as_deref()
|
||||
.or_else(|| {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
})
|
||||
.unwrap_or(&provider.id);
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
// 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)),
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
@@ -101,10 +155,16 @@ 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 {
|
||||
// 检测 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;
|
||||
@@ -123,6 +183,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
|
||||
@@ -254,6 +324,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()) {
|
||||
@@ -304,6 +379,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 strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
@@ -315,6 +399,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 的格式转换。
|
||||
@@ -347,6 +437,20 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
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();
|
||||
@@ -388,6 +492,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderName::from_static("x-interaction-type"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
// x-interaction-id 由 forwarder 按需注入(仅在有 session 时)
|
||||
(
|
||||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||||
HeaderValue::from_static("electron-fetch"),
|
||||
@@ -412,6 +517,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 mod models;
|
||||
@@ -58,6 +59,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 {
|
||||
@@ -70,6 +73,7 @@ impl ProviderType {
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::GitHubCopilot => true,
|
||||
ProviderType::CodexOAuth => true,
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
@@ -86,6 +90,7 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +106,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
|
||||
@@ -175,6 +183,7 @@ impl ProviderType {
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
ProviderType::GitHubCopilot => "github_copilot",
|
||||
ProviderType::CodexOAuth => "codex_oauth",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +208,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}")),
|
||||
}
|
||||
}
|
||||
@@ -228,7 +238,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()),
|
||||
}
|
||||
|
||||
@@ -85,14 +85,23 @@ struct ToolBlockState {
|
||||
name: String,
|
||||
started: bool,
|
||||
pending_args: String,
|
||||
/// 连续空白字符计数 — 用于检测 Copilot 无限换行 bug
|
||||
/// 当 function call 参数中出现连续 20+ 空白字符时,强制终止流
|
||||
consecutive_whitespace: usize,
|
||||
/// 是否已因无限空白 bug 被中止
|
||||
aborted: bool,
|
||||
}
|
||||
|
||||
/// 无限空白 bug 的连续空白字符阈值
|
||||
const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + 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 +116,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(pos) = buffer.find("\n\n") {
|
||||
let line = buffer[..pos].to_string();
|
||||
@@ -297,9 +305,16 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
name: String::new(),
|
||||
started: false,
|
||||
pending_args: String::new(),
|
||||
consecutive_whitespace: 0,
|
||||
aborted: false,
|
||||
}
|
||||
});
|
||||
|
||||
// 如果此 tool call 已被中止(无限空白 bug),跳过后续处理
|
||||
if state.aborted {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(id) = &tool_call.id {
|
||||
state.id = id.clone();
|
||||
}
|
||||
@@ -328,7 +343,22 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
.as_ref()
|
||||
.and_then(|f| f.arguments.clone());
|
||||
let immediate_delta = if let Some(args) = args_delta {
|
||||
if state.started {
|
||||
// 无限空白 bug 检测:跟踪连续空白字符
|
||||
for ch in args.chars() {
|
||||
if ch.is_whitespace() {
|
||||
state.consecutive_whitespace += 1;
|
||||
} else {
|
||||
state.consecutive_whitespace = 0;
|
||||
}
|
||||
}
|
||||
if state.consecutive_whitespace >= INFINITE_WHITESPACE_THRESHOLD {
|
||||
log::warn!(
|
||||
"[Copilot] 检测到无限空白 bug (tool: {}), 中止此 tool call 流",
|
||||
state.name
|
||||
);
|
||||
state.aborted = true;
|
||||
None
|
||||
} else if state.started {
|
||||
Some(args)
|
||||
} else {
|
||||
state.pending_args.push_str(&args);
|
||||
@@ -750,4 +780,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(pos) = buffer.find("\n\n") {
|
||||
@@ -1029,4 +1029,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn supports_reasoning_effort(model: &str) -> bool {
|
||||
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
|
||||
/// (supported by mainstream GPT models). Unknown values are ignored.
|
||||
/// 2. Fallback: `thinking.type` + `budget_tokens`:
|
||||
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
|
||||
/// - `adaptive` → `xhigh` (adaptive = maximum reasoning effort)
|
||||
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 000–15 999) / `high` (≥16 000)
|
||||
/// - `enabled` without budget → `high` (conservative default)
|
||||
/// - `disabled` / absent → `None`
|
||||
@@ -57,7 +57,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
// --- Priority 2: thinking.type + budget_tokens fallback ---
|
||||
let thinking = body.get("thinking")?;
|
||||
match thinking.get("type").and_then(|t| t.as_str()) {
|
||||
Some("adaptive") => Some("high"),
|
||||
Some("adaptive") => Some("xhigh"),
|
||||
Some("enabled") => {
|
||||
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
|
||||
match budget {
|
||||
@@ -71,10 +71,8 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
/// Anthropic 请求 → OpenAI Chat Completions 请求
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -113,6 +111,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
|
||||
@@ -174,12 +173,79 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
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;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
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();
|
||||
let mut inherited_cache_control: Option<Value> = None;
|
||||
let mut cache_control_conflict = false;
|
||||
let mut saw_cache_control = false;
|
||||
let mut saw_missing_cache_control = false;
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(cache_control) = message.get("cache_control") {
|
||||
saw_cache_control = true;
|
||||
match &inherited_cache_control {
|
||||
None => inherited_cache_control = Some(cache_control.clone()),
|
||||
Some(existing) if existing == cache_control => {}
|
||||
Some(_) => cache_control_conflict = true,
|
||||
}
|
||||
} else {
|
||||
saw_missing_cache_control = true;
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
let mut merged = json!({"role": "system", "content": parts.join("\n")});
|
||||
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
|
||||
if let Some(cache_control) = inherited_cache_control {
|
||||
merged["cache_control"] = cache_control;
|
||||
}
|
||||
}
|
||||
messages.insert(0, merged);
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
@@ -517,7 +583,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "claude-3-opus");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
@@ -533,7 +599,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
@@ -555,11 +621,76 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).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."
|
||||
);
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let input = json!({
|
||||
@@ -574,7 +705,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
@@ -594,7 +725,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
@@ -666,31 +797,19 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_cache_key() {
|
||||
fn test_anthropic_to_openai_does_not_inject_prompt_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "provider-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_no_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
@@ -716,7 +835,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
// System message cache_control preserved
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
// Text block cache_control preserved
|
||||
@@ -942,9 +1061,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive_maps_high() {
|
||||
fn test_thinking_adaptive_maps_xhigh() {
|
||||
let body = json!({"thinking": {"type": "adaptive"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -970,7 +1089,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
@@ -983,7 +1102,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "medium");
|
||||
}
|
||||
|
||||
@@ -996,7 +1115,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -1009,7 +1128,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
@@ -1022,8 +1141,8 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1034,7 +1153,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
@@ -1047,7 +1166,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert!(
|
||||
result.get("max_tokens").is_none(),
|
||||
"{model} should not have max_tokens"
|
||||
@@ -1067,7 +1186,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
@@ -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,12 +1042,12 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_thinking_adaptive_sets_reasoning_high() {
|
||||
fn test_responses_thinking_adaptive_sets_reasoning_xhigh() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
@@ -995,8 +1055,8 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -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(pos) = buffer.find("\n\n") {
|
||||
|
||||
@@ -528,7 +528,7 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let request_id = usage.dedup_request_id();
|
||||
|
||||
log::debug!(
|
||||
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
|
||||
@@ -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(pos) = buffer.find("\n\n") {
|
||||
@@ -784,6 +784,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
@@ -843,6 +844,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
|
||||
@@ -23,7 +23,6 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
/// 代理服务器状态(共享)
|
||||
#[derive(Clone)]
|
||||
@@ -275,11 +274,6 @@ impl ProxyServer {
|
||||
}
|
||||
|
||||
fn build_router(&self) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
// 健康检查
|
||||
.route("/health", get(handlers::health_check))
|
||||
@@ -328,7 +322,6 @@ impl ProxyServer {
|
||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||
// 提高默认请求体大小限制(避免 413 Payload Too Large)
|
||||
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
|
||||
.layer(cors)
|
||||
.with_state(self.state.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ fn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
/// 从 user_id 解析 session_id
|
||||
///
|
||||
/// 格式: `user_identifier_session_actual_session_id`
|
||||
fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
pub(super) fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
// 查找 "_session_" 分隔符
|
||||
if let Some(pos) = user_id.find("_session_") {
|
||||
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
|
||||
|
||||
+274
-1
@@ -4,9 +4,71 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str>
|
||||
.or_else(|| line.strip_prefix(&format!("{field}:")))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
use super::{append_utf8_safe, strip_sse_field};
|
||||
|
||||
#[test]
|
||||
fn strip_sse_field_accepts_optional_space() {
|
||||
@@ -28,4 +90,215 @@ mod tests {
|
||||
);
|
||||
assert_eq!(strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,59 @@ impl Default for OptimizerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copilot 优化器配置
|
||||
///
|
||||
/// 存储在 settings 表中,key = "copilot_optimizer_config"
|
||||
/// 解决 Copilot 代理消耗量异常问题(Issue #1813)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotOptimizerConfig {
|
||||
/// 总开关(默认开启 — 对 Copilot 用户至关重要)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// x-initiator 请求分类(默认开启,P0 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub request_classification: bool,
|
||||
/// Tool result 消息合并(默认开启,P1 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub tool_result_merging: bool,
|
||||
/// Compact 请求识别(默认开启,P2 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub compact_detection: bool,
|
||||
/// 确定性 Request ID(默认开启,P3 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub deterministic_request_id: bool,
|
||||
/// Subagent 检测(默认开启)— 识别 Claude Code 子代理请求,
|
||||
/// 设置 x-initiator=agent + x-interaction-type=conversation-subagent,避免子代理计费
|
||||
#[serde(default = "default_true")]
|
||||
pub subagent_detection: bool,
|
||||
/// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota)
|
||||
#[serde(default = "default_true")]
|
||||
pub warmup_downgrade: bool,
|
||||
/// Warmup 降级使用的模型(默认 "gpt-4o-mini")
|
||||
#[serde(default = "default_warmup_model")]
|
||||
pub warmup_model: String,
|
||||
}
|
||||
|
||||
fn default_warmup_model() -> String {
|
||||
"gpt-5-mini".to_string()
|
||||
}
|
||||
|
||||
impl Default for CopilotOptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_classification: true,
|
||||
tool_result_merging: true,
|
||||
compact_detection: true,
|
||||
deterministic_request_id: true,
|
||||
subagent_detection: true,
|
||||
warmup_downgrade: true,
|
||||
warmup_model: "gpt-4o-mini".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
|
||||
@@ -114,6 +114,7 @@ mod tests {
|
||||
cache_read_tokens: 200,
|
||||
cache_creation_tokens: 100,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
|
||||
@@ -144,6 +145,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
|
||||
@@ -165,6 +167,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
@@ -181,6 +184,7 @@ mod tests {
|
||||
cache_read_tokens: 1,
|
||||
cache_creation_tokens: 1,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
|
||||
|
||||
@@ -73,7 +73,7 @@ impl<'a> UsageLogger<'a> {
|
||||
});
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
"INSERT OR REPLACE 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,
|
||||
@@ -358,6 +358,7 @@ mod tests {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
logger.log_with_calculation(
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
|
||||
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
|
||||
|
||||
/// Token 使用量统计
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
@@ -18,6 +21,22 @@ pub struct TokenUsage {
|
||||
pub cache_creation_tokens: u32,
|
||||
/// 从响应中提取的实际模型名称(如果可用)
|
||||
pub model: Option<String>,
|
||||
/// 从响应中提取的消息 ID(用于跨源去重)
|
||||
///
|
||||
/// Claude API: `msg_xxx`,与 session JSONL 中的 `message.id` 一致
|
||||
#[serde(skip)]
|
||||
pub message_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
/// 生成与 session 日志共享的 request_id,用于跨源去重。
|
||||
/// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。
|
||||
pub fn dedup_request_id(&self) -> String {
|
||||
self.message_id
|
||||
.as_ref()
|
||||
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// API 类型
|
||||
@@ -39,6 +58,10 @@ impl TokenUsage {
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let message_id = body
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
||||
@@ -52,6 +75,7 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model,
|
||||
message_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,18 +84,23 @@ impl TokenUsage {
|
||||
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
|
||||
let mut usage = Self::default();
|
||||
let mut model: Option<String> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
match event_type {
|
||||
"message_start" => {
|
||||
// 从 message_start 提取模型名称
|
||||
if model.is_none() {
|
||||
if let Some(message) = event.get("message") {
|
||||
if let Some(message) = event.get("message") {
|
||||
if model.is_none() {
|
||||
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
|
||||
model = Some(m.to_string());
|
||||
}
|
||||
}
|
||||
if message_id.is_none() {
|
||||
if let Some(id) = message.get("id").and_then(|v| v.as_str()) {
|
||||
message_id = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
|
||||
// 从 message_start 获取 input_tokens(原生 Claude API)
|
||||
@@ -137,6 +166,7 @@ impl TokenUsage {
|
||||
|
||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||
usage.model = model;
|
||||
usage.message_id = message_id;
|
||||
Some(usage)
|
||||
} else {
|
||||
None
|
||||
@@ -153,6 +183,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,6 +233,7 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -245,6 +277,7 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,6 +372,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,6 +417,7 @@ impl TokenUsage {
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -433,6 +468,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: total_cache_read,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! 国产 Token Plan 额度查询服务
|
||||
//!
|
||||
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。
|
||||
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
|
||||
|
||||
use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ── 供应商检测 ──────────────────────────────────────────────
|
||||
|
||||
enum CodingPlanProvider {
|
||||
Kimi,
|
||||
ZhipuCn,
|
||||
ZhipuEn,
|
||||
MiniMaxCn,
|
||||
MiniMaxEn,
|
||||
}
|
||||
|
||||
fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
let url = base_url.to_lowercase();
|
||||
if url.contains("api.kimi.com/coding") {
|
||||
Some(CodingPlanProvider::Kimi)
|
||||
} else if url.contains("open.bigmodel.cn") || url.contains("bigmodel.cn") {
|
||||
Some(CodingPlanProvider::ZhipuCn)
|
||||
} else if url.contains("api.z.ai") {
|
||||
Some(CodingPlanProvider::ZhipuEn)
|
||||
} else if url.contains("api.minimaxi.com") {
|
||||
Some(CodingPlanProvider::MiniMaxCn)
|
||||
} else if url.contains("api.minimax.io") {
|
||||
Some(CodingPlanProvider::MiniMaxEn)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn millis_to_iso8601(ms: i64) -> Option<String> {
|
||||
let secs = ms / 1000;
|
||||
let nsecs = ((ms % 1000) * 1_000_000) as u32;
|
||||
chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339())
|
||||
}
|
||||
|
||||
/// 从 JSON 值提取重置时间,兼容字符串和数字格式
|
||||
/// - 字符串:直接返回(ISO 8601)
|
||||
/// - 数字:自动判断秒/毫秒并转为 ISO 8601
|
||||
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
if let Some(n) = value.as_i64() {
|
||||
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
|
||||
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
|
||||
return millis_to_iso8601(ms);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`)
|
||||
fn parse_f64(value: &serde_json::Value) -> Option<f64> {
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
|
||||
}
|
||||
|
||||
fn make_error(msg: String) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(msg),
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kimi For Coding ─────────────────────────────────────────
|
||||
|
||||
async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
.get("https://api.kimi.com/coding/v1/usages")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(std::time::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 SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
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 mut tiers = Vec::new();
|
||||
|
||||
// 5 小时窗口限额(优先显示)
|
||||
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
if let Some(detail) = limit_item.get("detail") {
|
||||
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
let resets_at = detail.get("resetTime").and_then(extract_reset_time);
|
||||
|
||||
let used = (limit - remaining).max(0.0);
|
||||
let utilization = if limit > 0.0 {
|
||||
(used / limit) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 总体用量(周限额)
|
||||
if let Some(usage) = body.get("usage") {
|
||||
let limit = usage.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = usage.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
let resets_at = usage.get("resetTime").and_then(extract_reset_time);
|
||||
|
||||
let used = (limit - remaining).max(0.0);
|
||||
let utilization = if limit > 0.0 {
|
||||
(used / limit) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tiers.push(QuotaTier {
|
||||
name: "weekly_limit".to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
});
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||
|
||||
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
// 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制)
|
||||
let resp = client
|
||||
.get("https://api.z.ai/api/monitor/usage/quota/limit")
|
||||
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Language", "en-US,en")
|
||||
.timeout(std::time::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 SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
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}")),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
|
||||
let msg = body
|
||||
.get("msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error: {msg}"));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => return make_error("Missing 'data' field in response".to_string()),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
let limit_type = limit_item
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let percentage = limit_item
|
||||
.get("percentage")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let next_reset = limit_item
|
||||
.get("nextResetTime")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(millis_to_iso8601);
|
||||
|
||||
if limit_type != "TOKENS_LIMIT" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization: percentage,
|
||||
resets_at: next_reset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 套餐等级存入 credential_message
|
||||
let level = data
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: level,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── MiniMax ─────────────────────────────────────────────────
|
||||
|
||||
async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let api_domain = if is_cn {
|
||||
"api.minimaxi.com"
|
||||
} else {
|
||||
"api.minimax.io"
|
||||
};
|
||||
let url = format!("https://{api_domain}/v1/api/openplatform/coding_plan/remains");
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(std::time::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 SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
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}")),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
if let Some(base_resp) = body.get("base_resp") {
|
||||
let status_code = base_resp
|
||||
.get("status_code")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
if status_code != 0 {
|
||||
let msg = base_resp
|
||||
.get("status_msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error (code {status_code}): {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) {
|
||||
// 只取第一个模型(MiniMax-M*,主力编程模型)
|
||||
if let Some(item) = model_remains.first() {
|
||||
// usage_count 是剩余量(满额=total,用完=0),需反转为已用百分比
|
||||
let interval_total = item
|
||||
.get("current_interval_total_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let interval_remaining = item
|
||||
.get("current_interval_usage_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let end_time = item.get("end_time").and_then(|v| v.as_i64());
|
||||
|
||||
if interval_total > 0.0 {
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization: ((interval_total - interval_remaining) / interval_total) * 100.0,
|
||||
resets_at: end_time.and_then(millis_to_iso8601),
|
||||
});
|
||||
}
|
||||
|
||||
// 周额度
|
||||
let weekly_total = item
|
||||
.get("current_weekly_total_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let weekly_remaining = item
|
||||
.get("current_weekly_usage_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let weekly_end = item.get("weekly_end_time").and_then(|v| v.as_i64());
|
||||
|
||||
if weekly_total > 0.0 {
|
||||
tiers.push(QuotaTier {
|
||||
name: "weekly_limit".to_string(),
|
||||
utilization: ((weekly_total - weekly_remaining) / weekly_total) * 100.0,
|
||||
resets_at: weekly_end.and_then(millis_to_iso8601),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: None,
|
||||
});
|
||||
}
|
||||
|
||||
let provider = match detect_provider(base_url) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let quota = match provider {
|
||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await,
|
||||
CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await,
|
||||
CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await,
|
||||
};
|
||||
|
||||
Ok(quota)
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
pub mod balance;
|
||||
pub mod coding_plan;
|
||||
pub mod config;
|
||||
pub mod env_checker;
|
||||
pub mod env_manager;
|
||||
pub mod mcp;
|
||||
pub mod model_fetch;
|
||||
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;
|
||||
pub mod subscription;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
pub mod webdav_auto_sync;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 模型列表获取服务
|
||||
//!
|
||||
//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。
|
||||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 获取到的模型信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FetchedModel {
|
||||
pub id: String,
|
||||
pub owned_by: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 兼容的 /v1/models 响应格式
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsResponse {
|
||||
data: Option<Vec<ModelEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
owned_by: Option<String>,
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
pub async fn fetch_models(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
is_full_url: bool,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
if api_key.is_empty() {
|
||||
return Err("API Key is required to fetch models".to_string());
|
||||
}
|
||||
|
||||
let models_url = build_models_url(base_url, is_full_url)?;
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let response = client
|
||||
.get(&models_url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
let resp: ModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
let mut models: Vec<FetchedModel> = resp
|
||||
.data
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| FetchedModel {
|
||||
id: m.id,
|
||||
owned_by: m.owned_by,
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 构造 /v1/models 的完整 URL
|
||||
fn build_models_url(base_url: &str, is_full_url: bool) -> Result<String, String> {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err("Base URL is empty".to_string());
|
||||
}
|
||||
|
||||
if is_full_url {
|
||||
// 尝试从完整端点 URL 推导 API 根路径
|
||||
// 例如: https://proxy.example.com/v1/chat/completions → https://proxy.example.com/v1/models
|
||||
if let Some(idx) = trimmed.find("/v1/") {
|
||||
return Ok(format!("{}/v1/models", &trimmed[..idx]));
|
||||
}
|
||||
// 如果没有 /v1/ 路径,直接去掉最后一段路径
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
let root = &trimmed[..idx];
|
||||
if root.contains("://") && root.len() > root.find("://").unwrap() + 3 {
|
||||
return Ok(format!("{root}/v1/models"));
|
||||
}
|
||||
}
|
||||
return Err("Cannot derive models endpoint from full URL".to_string());
|
||||
}
|
||||
|
||||
// 常规情况: base_url 是 API 根路径
|
||||
// 如果已经包含 /v1 路径,直接追加 /models
|
||||
if trimmed.ends_with("/v1") {
|
||||
return Ok(format!("{trimmed}/models"));
|
||||
}
|
||||
|
||||
Ok(format!("{trimmed}/v1/models"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_basic() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.siliconflow.cn", false).unwrap(),
|
||||
"https://api.siliconflow.cn/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_trailing_slash() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_with_v1() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/v1", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_full_url() {
|
||||
assert_eq!(
|
||||
build_models_url("https://proxy.example.com/v1/chat/completions", true).unwrap(),
|
||||
"https://proxy.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_empty() {
|
||||
assert!(build_models_url("", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response() {
|
||||
let json = r#"{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"openai"},{"id":"claude-3-sonnet","object":"model","owned_by":"anthropic"}]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data.len(), 2);
|
||||
assert_eq!(data[0].id, "gpt-4");
|
||||
assert_eq!(data[0].owned_by.as_deref(), Some("openai"));
|
||||
assert_eq!(data[1].id, "claude-3-sonnet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_no_owned_by() {
|
||||
let json = r#"{"object":"list","data":[{"id":"my-model","object":"model"}]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data[0].id, "my-model");
|
||||
assert!(data[0].owned_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_empty_data() {
|
||||
let json = r#"{"object":"list","data":[]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.data.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -1098,7 +1099,7 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
// One-time auth type detection to avoid repeated detection
|
||||
let auth_type = detect_gemini_auth_type(provider);
|
||||
|
||||
let mut env_map = json_to_env(&provider.settings_config)?;
|
||||
let env_map = json_to_env(&provider.settings_config)?;
|
||||
|
||||
// Prepare config to write to ~/.gemini/settings.json
|
||||
// Behavior:
|
||||
@@ -1142,17 +1143,12 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => {
|
||||
// Google official uses OAuth, clear env
|
||||
env_map.clear();
|
||||
// Google Official uses OAuth, no API key validation needed.
|
||||
// Write user's env vars as-is (e.g. GEMINI_MODEL, custom vars).
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Packycode => {
|
||||
// PackyCode provider, uses API Key (strict validation on switch)
|
||||
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Generic => {
|
||||
// Generic provider, uses API Key (strict validation on switch)
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
// API Key mode -- require GEMINI_API_KEY
|
||||
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
@@ -1208,11 +1204,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 +1261,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 +1275,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;
|
||||
}
|
||||
|
||||
@@ -1407,6 +1407,16 @@ impl ProviderService {
|
||||
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
|
||||
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
|
||||
|
||||
// Block switching to official providers when proxy takeover is active.
|
||||
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
|
||||
if should_hot_switch && _provider.category.as_deref() == Some("official") {
|
||||
return Err(AppError::localized(
|
||||
"switch.official_blocked_by_proxy",
|
||||
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
|
||||
"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
|
||||
));
|
||||
}
|
||||
|
||||
if should_hot_switch {
|
||||
// Proxy takeover mode: hot-switch only, don't write Live config
|
||||
log::info!(
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::services::provider::{
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||||
@@ -375,6 +376,26 @@ impl ProxyService {
|
||||
|
||||
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
||||
let _ = self.db.set_live_takeover_active(true).await;
|
||||
|
||||
// 8) Warn if the current provider is official (risk of account ban via proxy)
|
||||
if let Ok(Some(current_id)) =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app)
|
||||
{
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) {
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
if let Some(handle) = self.app_handle.read().await.as_ref() {
|
||||
let _ = handle.emit(
|
||||
"proxy-official-warning",
|
||||
serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"providerName": provider.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1548,6 +1569,14 @@ impl ProxyService {
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
|
||||
// Defense-in-depth: block official providers during proxy takeover
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let logical_target_changed =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
|
||||
.map_err(|e| format!("读取当前供应商失败: {e}"))?
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
//! 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!(
|
||||
"{}{}",
|
||||
crate::proxy::usage::parser::SESSION_REQUEST_ID_PREFIX,
|
||||
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()),
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
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,815 @@
|
||||
//! 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()),
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
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,504 @@
|
||||
//! 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()),
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
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,35 @@ 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 if temp_dir.join("SKILL.md").exists() {
|
||||
// 根级 Skill:仓库本身就是 skill,SKILL.md 直接在解压根目录
|
||||
log::info!(
|
||||
"Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir",
|
||||
target_name,
|
||||
);
|
||||
source = temp_dir.clone();
|
||||
} 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 +750,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 +771,8 @@ impl SkillService {
|
||||
readme_url,
|
||||
apps: SkillApps::only(current_app),
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
@@ -706,6 +832,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 +1332,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 +1527,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 +1543,8 @@ impl SkillService {
|
||||
readme_url,
|
||||
apps,
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
@@ -1514,6 +2056,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 +2539,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 +2554,8 @@ impl SkillService {
|
||||
readme_url: None,
|
||||
apps: SkillApps::only(current_app),
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
content_hash,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
@@ -2116,6 +2695,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 +2928,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 +2941,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)?;
|
||||
|
||||
@@ -197,6 +197,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 {
|
||||
@@ -210,9 +218,8 @@ impl StreamCheckService {
|
||||
.or_else(|| adapter.extract_auth(provider))
|
||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||
|
||||
// 获取 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);
|
||||
// 获取 HTTP 客户端
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
@@ -229,6 +236,7 @@ impl StreamCheckService {
|
||||
request_timeout,
|
||||
provider,
|
||||
claude_api_format_override.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -252,56 +260,22 @@ 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 处理")
|
||||
}
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
let tested_at = chrono::Utc::now().timestamp();
|
||||
|
||||
match result {
|
||||
Ok((status_code, model)) => {
|
||||
let health_status =
|
||||
Self::determine_status(response_time, config.degraded_threshold_ms);
|
||||
Ok(StreamCheckResult {
|
||||
status: health_status,
|
||||
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) => Ok(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,
|
||||
}),
|
||||
}
|
||||
Ok(Self::build_stream_check_result(
|
||||
result,
|
||||
response_time,
|
||||
config.degraded_threshold_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// Claude 流式检查
|
||||
@@ -309,6 +283,10 @@ impl StreamCheckService {
|
||||
/// 根据供应商的 api_format 选择请求格式:
|
||||
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
|
||||
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
|
||||
///
|
||||
/// `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,
|
||||
@@ -319,6 +297,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;
|
||||
@@ -357,11 +336,19 @@ 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_openai_chat {
|
||||
anthropic_to_openai(anthropic_body, Some(&provider.id))
|
||||
anthropic_to_openai(anthropic_body)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else {
|
||||
anthropic_body
|
||||
@@ -445,6 +432,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)
|
||||
@@ -456,7 +452,7 @@ impl StreamCheckService {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
return Err(Self::http_status_error(status, error_text));
|
||||
}
|
||||
|
||||
// 流式读取:只需首个 chunk
|
||||
@@ -536,7 +532,7 @@ impl StreamCheckService {
|
||||
if i == 0 && status == 404 && urls.len() > 1 {
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
return Err(Self::http_status_error(status, error_text));
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
@@ -565,6 +561,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
|
||||
@@ -584,11 +581,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()
|
||||
@@ -599,7 +607,7 @@ impl StreamCheckService {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||
return Err(Self::http_status_error(status, error_text));
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
@@ -613,6 +621,459 @@ 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 client = crate::proxy::http_client::get();
|
||||
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) => {
|
||||
let (http_status, message) = match &e {
|
||||
AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
Self::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message,
|
||||
response_time_ms: Some(response_time),
|
||||
http_status,
|
||||
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
|
||||
@@ -649,6 +1110,39 @@ impl StreamCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 HTTP 状态码错误,截断过长的响应体
|
||||
fn http_status_error(status: u16, body: String) -> AppError {
|
||||
let body = if body.len() > 200 {
|
||||
// 安全截断:找到 200 字节内最近的 char 边界
|
||||
let mut end = 200;
|
||||
while end > 0 && !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &body[..end])
|
||||
} else {
|
||||
body
|
||||
};
|
||||
AppError::HttpStatus { status, body }
|
||||
}
|
||||
|
||||
/// 将 HTTP 状态码映射为简短的分类标签
|
||||
pub(crate) fn classify_http_status(status: u16) -> &'static str {
|
||||
match status {
|
||||
400 => "Bad request (400)",
|
||||
401 => "Auth rejected (401)",
|
||||
402 => "Payment required (402)",
|
||||
403 => "Access denied (403)",
|
||||
404 => "Not found (404)",
|
||||
429 => "Rate limited (429)",
|
||||
500 => "Internal server error (500)",
|
||||
502 => "Bad gateway (502)",
|
||||
503 => "Service unavailable (503)",
|
||||
504 => "Gateway timeout (504)",
|
||||
s if (500..600).contains(&s) => "Server error",
|
||||
_ => "HTTP error",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_test_model(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
@@ -810,6 +1304,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!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "claude";
|
||||
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut first_user_message: Option<String> = None;
|
||||
|
||||
// Extract metadata from head lines
|
||||
// Extract metadata and first user message from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -151,11 +153,41 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
if created_at.is_none() {
|
||||
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
}
|
||||
// Extract first real user message as title candidate
|
||||
// Skip system-injected caveats and slash commands (e.g. /clear, /compact)
|
||||
if first_user_message.is_none() {
|
||||
let is_user = value.get("type").and_then(Value::as_str) == Some("user")
|
||||
|| value
|
||||
.get("message")
|
||||
.and_then(|m| m.get("role"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("user");
|
||||
if is_user {
|
||||
if let Some(message) = value.get("message") {
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty()
|
||||
&& !trimmed.contains("<local-command-caveat>")
|
||||
&& !trimmed.starts_with("<command-name>")
|
||||
{
|
||||
first_user_message = Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if session_id.is_some()
|
||||
&& project_dir.is_some()
|
||||
&& created_at.is_some()
|
||||
&& first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last_active_at and summary from tail lines (reverse order)
|
||||
// Extract last_active_at, summary, and custom-title from tail lines (reverse order)
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
let mut custom_title: Option<String> = None;
|
||||
|
||||
for line in tail.iter().rev() {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
@@ -165,6 +197,16 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
if last_active_at.is_none() {
|
||||
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
}
|
||||
// Look for custom-title entry (take the last one, i.e. first in reverse)
|
||||
if custom_title.is_none()
|
||||
&& value.get("type").and_then(Value::as_str) == Some("custom-title")
|
||||
{
|
||||
custom_title = value
|
||||
.get("customTitle")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
}
|
||||
if summary.is_none() {
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
@@ -176,7 +218,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_active_at.is_some() && summary.is_some() {
|
||||
if last_active_at.is_some() && summary.is_some() && custom_title.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -184,10 +226,16 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
// Title priority: custom-title > first user message > directory basename
|
||||
let title = custom_title
|
||||
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
|
||||
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
|
||||
.or_else(|| {
|
||||
project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|v| v.to_string())
|
||||
});
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
@@ -336,4 +384,117 @@ mod tests {
|
||||
assert_eq!(msgs[0].role, "user");
|
||||
assert!(msgs[0].content.contains("Please continue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_first_user_message_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-abc.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"sessionId\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"sessionId\":\"session-abc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
|
||||
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_custom_title_overrides_first_message() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-def.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"sessionId\":\"session-def\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"sessionId\":\"session-def\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
|
||||
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n",
|
||||
"{\"type\":\"custom-title\",\"customTitle\":\"fix-login-bug\",\"sessionId\":\"session-def\"}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("fix-login-bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-ghi.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"sessionId\":\"session-ghi\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
// No user message and no custom-title → falls back to dir basename
|
||||
assert_eq!(meta.title.as_deref(), Some("my-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_truncates_long_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-trunc.jsonl");
|
||||
let long_msg = "a".repeat(200);
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{{\"sessionId\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
|
||||
{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"sessionId\":\"session-trunc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
let title = meta.title.unwrap();
|
||||
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
|
||||
assert!(title.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_new_format_with_snapshot() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-new.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
|
||||
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"请帮我重构这个函数\"},\"sessionId\":\"session-new\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n",
|
||||
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"OK\"},\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("请帮我重构这个函数"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_skips_command_caveat_and_slash_commands() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-clear.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
|
||||
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<local-command-caveat>Caveat: The messages below were generated by the user while running local commands.</local-command-caveat>\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n",
|
||||
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<command-name>/clear</command-name>\\n<command-message>clear</command-message>\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:01Z\",\"cwd\":\"/tmp/project\"}\n",
|
||||
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:00:02Z\",\"cwd\":\"/tmp/project\"}\n",
|
||||
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"帮我看看工作区的改动\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("帮我看看工作区的改动"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "codex";
|
||||
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut first_user_message: Option<String> = None;
|
||||
|
||||
// Extract metadata from head lines
|
||||
// Extract metadata and first user message from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -158,6 +160,29 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extract first user message as title candidate
|
||||
if first_user_message.is_none()
|
||||
&& value.get("type").and_then(Value::as_str) == Some("response_item")
|
||||
{
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if payload.get("type").and_then(Value::as_str) == Some("message")
|
||||
&& payload.get("role").and_then(Value::as_str) == Some("user")
|
||||
{
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") {
|
||||
first_user_message = Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if session_id.is_some()
|
||||
&& project_dir.is_some()
|
||||
&& created_at.is_some()
|
||||
&& first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last_active_at and summary from tail lines (reverse order)
|
||||
@@ -190,10 +215,14 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
let title = first_user_message
|
||||
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
|
||||
.or_else(|| {
|
||||
project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|v| v.to_string())
|
||||
});
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
@@ -261,6 +290,82 @@ mod tests {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_first_user_message_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_skips_agents_md_injection() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"<permissions>\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\n<INSTRUCTIONS>Do stuff</INSTRUCTIONS>\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
// Should skip AGENTS.md injection and use the real user message
|
||||
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/my-project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
// No user message → falls back to dir basename
|
||||
assert_eq!(meta.title.as_deref(), Some("my-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_truncates_long_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
let long_msg = "a".repeat(200);
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\
|
||||
{{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
let title = meta.title.unwrap();
|
||||
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
|
||||
assert!(title.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_includes_function_call_and_output() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
@@ -12,10 +13,20 @@ use crate::{
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "openclaw";
|
||||
|
||||
/// Strip trailing `\n[message_id: ...]` metadata injected by OpenClaw gateway.
|
||||
fn strip_message_id_suffix(text: &str) -> &str {
|
||||
if let Some(pos) = text.rfind("\n[message_id:") {
|
||||
text[..pos].trim_end()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let agents_dir = get_openclaw_dir().join("agents");
|
||||
if !agents_dir.exists() {
|
||||
@@ -46,22 +57,15 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let display_names = load_display_names(&sessions_dir);
|
||||
|
||||
for entry in session_entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
// Skip sessions.json index file
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n == "sessions.json")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
if let Some(meta) = parse_session(&path, Some(&display_names)) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +123,7 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
let meta = parse_session(path, None).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw session metadata: {}",
|
||||
path.display()
|
||||
@@ -149,15 +153,46 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<boo
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
/// Read `sessions.json` index and build a sessionId → displayName lookup map.
|
||||
/// Returns an empty map if the file does not exist or cannot be parsed.
|
||||
fn load_display_names(sessions_dir: &Path) -> HashMap<String, String> {
|
||||
let index_path = sessions_dir.join("sessions.json");
|
||||
let content = match std::fs::read_to_string(&index_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
let index: serde_json::Map<String, Value> = match serde_json::from_str(&content) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for (_key, entry) in &index {
|
||||
if let (Some(id), Some(name)) = (
|
||||
entry.get("sessionId").and_then(Value::as_str),
|
||||
entry.get("displayName").and_then(Value::as_str),
|
||||
) {
|
||||
if !name.is_empty() {
|
||||
map.insert(id.to_string(), name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn parse_session(
|
||||
path: &Path,
|
||||
display_names: Option<&HashMap<String, String>>,
|
||||
) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
let mut first_user_message: Option<String> = None;
|
||||
|
||||
// Extract metadata and first message summary from head lines
|
||||
// Extract metadata, summary, and first user message from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -189,15 +224,31 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// OpenClaw summary is the first message content
|
||||
if event_type == "message" && summary.is_none() {
|
||||
if event_type == "message" {
|
||||
if let Some(message) = value.get("message") {
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
summary = Some(text);
|
||||
let cleaned = strip_message_id_suffix(&text);
|
||||
if !cleaned.trim().is_empty() {
|
||||
if first_user_message.is_none()
|
||||
&& message.get("role").and_then(Value::as_str) == Some("user")
|
||||
{
|
||||
first_user_message = Some(cleaned.trim().to_string());
|
||||
}
|
||||
if summary.is_none() {
|
||||
summary = Some(cleaned.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if session_id.is_some()
|
||||
&& cwd.is_some()
|
||||
&& created_at.is_some()
|
||||
&& summary.is_some()
|
||||
&& first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last_active_at from tail lines (reverse order)
|
||||
@@ -221,10 +272,17 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
});
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = cwd
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string());
|
||||
// Title priority: displayName (from sessions.json) > first user message > dir basename
|
||||
let title = display_names
|
||||
.and_then(|m| m.get(&session_id))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|t| truncate_summary(t, TITLE_MAX_CHARS))
|
||||
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
|
||||
.or_else(|| {
|
||||
cwd.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
@@ -284,6 +342,93 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_first_user_message_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-abc.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_display_name_overrides_user_message() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path();
|
||||
|
||||
let path = sessions_dir.join("session-abc.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
std::fs::write(
|
||||
sessions_dir.join("sessions.json"),
|
||||
r#"{
|
||||
"agent:main:main": {
|
||||
"sessionId": "session-abc",
|
||||
"displayName": "重构登录模块"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write index");
|
||||
|
||||
let display_names = load_display_names(sessions_dir);
|
||||
let meta = parse_session(&path, Some(&display_names)).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("重构登录模块"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-def.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-def\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
// No user message and no displayName → falls back to dir basename
|
||||
assert_eq!(meta.title.as_deref(), Some("my-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_truncates_long_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-trunc.jsonl");
|
||||
let long_msg = "a".repeat(200);
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{{\"type\":\"session\",\"id\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
|
||||
{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
let title = meta.title.unwrap();
|
||||
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
|
||||
assert!(title.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_updates_index_and_removes_jsonl() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -5,6 +5,9 @@ use std::path::Path;
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Maximum number of characters for session titles (shared across providers).
|
||||
pub const TITLE_MAX_CHARS: usize = 80;
|
||||
|
||||
/// Read the first `head_n` lines and last `tail_n` lines from a file.
|
||||
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
|
||||
pub fn read_head_tail_lines(
|
||||
|
||||
@@ -20,6 +20,7 @@ pub fn launch_terminal(
|
||||
"ghostty" => launch_ghostty(command, cwd),
|
||||
"kitty" => launch_kitty(command, cwd),
|
||||
"wezterm" => launch_wezterm(command, cwd),
|
||||
"kaku" => launch_kaku(command, cwd),
|
||||
"alacritty" => launch_alacritty(command, cwd),
|
||||
"custom" => launch_custom(command, cwd, custom_config),
|
||||
_ => Err(format!("Unsupported terminal target: {target}")),
|
||||
@@ -153,25 +154,10 @@ fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// wezterm start --cwd ... -- command
|
||||
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
|
||||
|
||||
let full_command = build_shell_command(command, None);
|
||||
|
||||
let mut args = vec!["-na", "WezTerm", "--args", "start"];
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
args.push("--cwd");
|
||||
args.push(dir);
|
||||
}
|
||||
|
||||
// Invoke shell to run the command string (to handle pipes, etc)
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
args.push("--");
|
||||
args.push(&shell);
|
||||
args.push("-c");
|
||||
args.push(&full_command);
|
||||
let args = build_wezterm_compatible_args("WezTerm", command, cwd);
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.args(args.iter().map(String::as_str))
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
|
||||
|
||||
@@ -182,6 +168,54 @@ fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint.
|
||||
let args = build_wezterm_compatible_args("Kaku", command, cwd);
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(args.iter().map(String::as_str))
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Kaku: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch Kaku.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec<String> {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell)
|
||||
}
|
||||
|
||||
fn build_wezterm_compatible_args_with_shell(
|
||||
app_name: &str,
|
||||
command: &str,
|
||||
cwd: Option<&str>,
|
||||
shell: &str,
|
||||
) -> Vec<String> {
|
||||
let full_command = build_shell_command(command, None);
|
||||
let mut args = vec![
|
||||
"-na".to_string(),
|
||||
app_name.to_string(),
|
||||
"--args".to_string(),
|
||||
"start".to_string(),
|
||||
];
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
args.push("--cwd".to_string());
|
||||
args.push(dir.to_string());
|
||||
}
|
||||
|
||||
// Invoke shell to run the command string (to handle pipes, etc)
|
||||
args.push("--".to_string());
|
||||
args.push(shell.to_string());
|
||||
args.push("-c".to_string());
|
||||
args.push(full_command);
|
||||
args
|
||||
}
|
||||
|
||||
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
|
||||
let full_command = build_shell_command(command, None);
|
||||
@@ -305,4 +339,30 @@ mod tests {
|
||||
"raw:echo foo\\\\\\\\bar\\npwd\\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wezterm_compatible_terminals_use_start_and_cwd_arguments() {
|
||||
let args = build_wezterm_compatible_args_with_shell(
|
||||
"Kaku",
|
||||
"claude --resume abc-123",
|
||||
Some("/tmp/project dir"),
|
||||
"/bin/zsh",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-na".to_string(),
|
||||
"Kaku".to_string(),
|
||||
"--args".to_string(),
|
||||
"start".to_string(),
|
||||
"--cwd".to_string(),
|
||||
"/tmp/project dir".to_string(),
|
||||
"--".to_string(),
|
||||
"/bin/zsh".to_string(),
|
||||
"-c".to_string(),
|
||||
"claude --resume abc-123".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
@@ -175,6 +175,8 @@ pub struct AppSettings {
|
||||
pub show_in_tray: bool,
|
||||
#[serde(default = "default_minimize_to_tray_on_close")]
|
||||
pub minimize_to_tray_on_close: bool,
|
||||
#[serde(default)]
|
||||
pub use_app_window_controls: bool,
|
||||
/// 是否启用 Claude 插件联动
|
||||
#[serde(default)]
|
||||
pub enable_claude_plugin_integration: bool,
|
||||
@@ -205,6 +207,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 +253,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")]
|
||||
@@ -264,7 +275,7 @@ pub struct AppSettings {
|
||||
|
||||
// ===== 终端设置 =====
|
||||
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku"
|
||||
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
|
||||
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -284,6 +295,7 @@ impl Default for AppSettings {
|
||||
Self {
|
||||
show_in_tray: true,
|
||||
minimize_to_tray_on_close: true,
|
||||
use_app_window_controls: false,
|
||||
enable_claude_plugin_integration: false,
|
||||
skip_claude_onboarding: false,
|
||||
launch_on_startup: false,
|
||||
@@ -294,6 +306,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 +321,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 +657,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)
|
||||
|
||||
+76
-84
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
|
||||
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem};
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
@@ -13,7 +13,7 @@ use crate::store::AppState;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TrayTexts {
|
||||
pub show_main: &'static str,
|
||||
pub no_provider_hint: &'static str,
|
||||
pub no_providers_label: &'static str,
|
||||
pub lightweight_mode: &'static str,
|
||||
pub quit: &'static str,
|
||||
pub _auto_label: &'static str,
|
||||
@@ -24,22 +24,21 @@ impl TrayTexts {
|
||||
match language {
|
||||
"en" => Self {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
no_providers_label: "(no providers)",
|
||||
lightweight_mode: "Lightweight Mode",
|
||||
quit: "Quit",
|
||||
_auto_label: "Auto (Failover)",
|
||||
},
|
||||
"ja" => Self {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
no_providers_label: "(プロバイダーなし)",
|
||||
lightweight_mode: "軽量モード",
|
||||
quit: "終了",
|
||||
_auto_label: "自動 (フェイルオーバー)",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
no_providers_label: "(无供应商)",
|
||||
lightweight_mode: "轻量模式",
|
||||
quit: "退出",
|
||||
_auto_label: "自动 (故障转移)",
|
||||
@@ -52,7 +51,6 @@ impl TrayTexts {
|
||||
pub struct TrayAppSection {
|
||||
pub app_type: AppType,
|
||||
pub prefix: &'static str,
|
||||
pub header_id: &'static str,
|
||||
pub empty_id: &'static str,
|
||||
pub header_label: &'static str,
|
||||
pub log_name: &'static str,
|
||||
@@ -65,7 +63,6 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
header_id: "claude_header",
|
||||
empty_id: "claude_empty",
|
||||
header_label: "Claude",
|
||||
log_name: "Claude",
|
||||
@@ -73,7 +70,6 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Codex,
|
||||
prefix: "codex_",
|
||||
header_id: "codex_header",
|
||||
empty_id: "codex_empty",
|
||||
header_label: "Codex",
|
||||
log_name: "Codex",
|
||||
@@ -81,54 +77,18 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Gemini,
|
||||
prefix: "gemini_",
|
||||
header_id: "gemini_header",
|
||||
empty_id: "gemini_empty",
|
||||
header_label: "Gemini",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
/// 添加供应商分区到菜单
|
||||
fn append_provider_section<'a>(
|
||||
app: &'a tauri::AppHandle,
|
||||
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
_app_state: &AppState,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
};
|
||||
|
||||
let header = MenuItem::with_id(
|
||||
app,
|
||||
section.header_id,
|
||||
section.header_label,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&header);
|
||||
|
||||
if manager.providers.is_empty() {
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
section.empty_id,
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
// Auto (Failover) menu item is hidden from tray; the feature is still
|
||||
// accessible from the Settings page. Keep the surrounding code intact so
|
||||
// it can be re-enabled easily in the future.
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
/// 对供应商列表排序:sort_index → created_at → name
|
||||
fn sort_providers(
|
||||
providers: &indexmap::IndexMap<String, crate::provider::Provider>,
|
||||
) -> Vec<(&String, &crate::provider::Provider)> {
|
||||
let mut sorted: Vec<_> = providers.iter().collect();
|
||||
sorted.sort_by(|(_, a), (_, b)| {
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
@@ -145,22 +105,7 @@ fn append_provider_section<'a>(
|
||||
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
|
||||
Ok(menu_builder)
|
||||
sorted
|
||||
}
|
||||
|
||||
/// 处理供应商托盘事件
|
||||
@@ -352,10 +297,11 @@ pub fn create_tray_menu(
|
||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
// Only add visible app sections
|
||||
// Pre-compute proxy running state (used to disable official providers in tray menu)
|
||||
let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running());
|
||||
|
||||
// 每个应用类型折叠为子菜单,避免供应商过多时菜单过长
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
// Skip hidden apps
|
||||
if !visible_apps.is_visible(§ion.app_type) {
|
||||
continue;
|
||||
}
|
||||
@@ -363,26 +309,68 @@ pub fn create_tray_menu(
|
||||
let app_type_str = section.app_type.as_str();
|
||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||
|
||||
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
||||
let current_id =
|
||||
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let manager = crate::provider::ProviderManager {
|
||||
providers,
|
||||
current: current_id,
|
||||
};
|
||||
if providers.is_empty() {
|
||||
// 空供应商:显示禁用的菜单项
|
||||
let label = format!("{} {}", section.header_label, tray_texts.no_providers_label);
|
||||
let empty_item = MenuItem::with_id(app, section.empty_id, &label, false, None::<&str>)
|
||||
.map_err(|e| {
|
||||
AppError::Message(format!("创建{}空提示失败: {e}", section.log_name))
|
||||
})?;
|
||||
menu_builder = menu_builder.item(&empty_item);
|
||||
} else {
|
||||
// 有供应商:构建子菜单
|
||||
let current_name = providers.get(¤t_id).map(|p| p.name.as_str());
|
||||
let submenu_label = match current_name {
|
||||
Some(name) => format!("{} · {}", section.header_label, name),
|
||||
None => section.header_label.to_string(),
|
||||
};
|
||||
let submenu_id = format!("submenu_{}", app_type_str);
|
||||
|
||||
menu_builder = append_provider_section(
|
||||
app,
|
||||
menu_builder,
|
||||
Some(&manager),
|
||||
section,
|
||||
&tray_texts,
|
||||
app_state,
|
||||
)?;
|
||||
// Check if this app is under proxy takeover (for disabling official providers)
|
||||
let is_app_taken_over = is_proxy_running
|
||||
&& (futures::executor::block_on(app_state.db.get_live_backup(app_type_str))
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
|| app_state
|
||||
.proxy_service
|
||||
.detect_takeover_in_live_config_for_app(§ion.app_type));
|
||||
|
||||
let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label);
|
||||
|
||||
for (id, provider) in sort_providers(&providers) {
|
||||
let is_current = current_id == *id;
|
||||
let is_official_blocked =
|
||||
is_app_taken_over && provider.category.as_deref() == Some("official");
|
||||
let label = if is_official_blocked {
|
||||
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
|
||||
} else {
|
||||
provider.name.clone()
|
||||
};
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&label,
|
||||
!is_official_blocked, // disabled when blocked
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name))
|
||||
})?;
|
||||
submenu_builder = submenu_builder.item(&item);
|
||||
}
|
||||
|
||||
let submenu = submenu_builder.build().map_err(|e| {
|
||||
AppError::Message(format!("构建{}子菜单失败: {e}", section.log_name))
|
||||
})?;
|
||||
menu_builder = menu_builder.item(&submenu);
|
||||
}
|
||||
|
||||
// 在每个 section 后添加分隔符
|
||||
menu_builder = menu_builder.separator();
|
||||
}
|
||||
|
||||
@@ -456,6 +444,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);
|
||||
|
||||
@@ -104,8 +104,7 @@ pub async fn execute_usage_script(
|
||||
)
|
||||
})?;
|
||||
|
||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
|
||||
// 5. 验证请求 URL(HTTPS 强制 + 同源检查)
|
||||
validate_request_url(&request.url, base_url, is_custom_template)?;
|
||||
|
||||
// 6. 发送 HTTP 请求
|
||||
@@ -468,19 +467,10 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
||||
));
|
||||
}
|
||||
|
||||
// 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
|
||||
if is_suspicious_hostname(hostname) {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.base_url_suspicious",
|
||||
"base_url 包含可疑的主机名",
|
||||
"base_url contains a suspicious hostname",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
||||
/// 验证请求 URL 是否安全(HTTPS 强制 + 同源检查)
|
||||
fn validate_request_url(
|
||||
request_url: &str,
|
||||
base_url: &str,
|
||||
@@ -561,151 +551,11 @@ fn validate_request_url(
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||
if let Some(host) = parsed_request.host_str() {
|
||||
let base_host = parsed_base.host_str().unwrap_or("");
|
||||
|
||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.private_ip_blocked",
|
||||
"禁止访问私有 IP 地址",
|
||||
"Access to private IP addresses is blocked",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自定义模板模式:没有 base_url,需要额外的安全检查
|
||||
// 禁止访问私有 IP 地址(SSRF 防护)
|
||||
if let Some(host) = parsed_request.host_str() {
|
||||
if is_private_ip(host) && !is_request_loopback {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.private_ip_blocked",
|
||||
"禁止访问私有 IP 地址(localhost 除外)",
|
||||
"Access to private IP addresses is blocked (localhost allowed)",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否为私有 IP 地址
|
||||
fn is_private_ip(host: &str) -> bool {
|
||||
// localhost 检查
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 尝试解析为IP地址
|
||||
if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {
|
||||
return is_private_ip_addr(ip_addr);
|
||||
}
|
||||
|
||||
// 如果不是IP地址,不是私有IP
|
||||
false
|
||||
}
|
||||
|
||||
/// 使用标准库API检查IP地址是否为私有地址
|
||||
fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(ipv4) => {
|
||||
let octets = ipv4.octets();
|
||||
|
||||
// 0.0.0.0/8 (包括未指定地址)
|
||||
if octets[0] == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// RFC1918 私有地址范围
|
||||
// 10.0.0.0/8
|
||||
if octets[0] == 10 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
||||
if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 192.168.0.0/16
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 其他特殊地址
|
||||
// 169.254.0.0/16 (链路本地地址)
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 127.0.0.0/8 (环回地址)
|
||||
if octets[0] == 127 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
std::net::IpAddr::V6(ipv6) => {
|
||||
// IPv6 私有地址检查 - 使用标准库方法
|
||||
|
||||
// ::1 (环回地址)
|
||||
if ipv6.is_loopback() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 唯一本地地址 (fc00::/7)
|
||||
// Rust 1.70+ 可以使用 ipv6.is_unique_local()
|
||||
// 但为了兼容性,我们手动检查
|
||||
let first_segment = ipv6.segments()[0];
|
||||
if (first_segment & 0xfe00) == 0xfc00 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 链路本地地址 (fe80::/10)
|
||||
if (first_segment & 0xffc0) == 0xfe80 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未指定地址 ::
|
||||
if ipv6.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否为可疑的主机名(只检查明显不安全的模式)
|
||||
fn is_suspicious_hostname(hostname: &str) -> bool {
|
||||
// 空主机名
|
||||
if hostname.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查明显的主机名格式问题
|
||||
if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
|
||||
if hostname.parse::<std::net::IpAddr>().is_ok() {
|
||||
// IP地址格式的,在这里不直接拒绝,让同源检查来处理
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否包含明显不当的字符
|
||||
let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
|
||||
if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 判断 URL 是否指向本机(localhost / loopback)
|
||||
fn is_loopback_host(url: &Url) -> bool {
|
||||
match url.host() {
|
||||
@@ -720,77 +570,6 @@ fn is_loopback_host(url: &Url) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_private_ip_validation() {
|
||||
// 测试IPv4私网地址
|
||||
|
||||
// RFC1918私网地址 - 应该返回true
|
||||
assert!(is_private_ip("10.0.0.1"));
|
||||
assert!(is_private_ip("10.255.255.254"));
|
||||
assert!(is_private_ip("172.16.0.1"));
|
||||
assert!(is_private_ip("172.31.255.255"));
|
||||
assert!(is_private_ip("192.168.0.1"));
|
||||
assert!(is_private_ip("192.168.255.255"));
|
||||
|
||||
// 链路本地地址 - 应该返回true
|
||||
assert!(is_private_ip("169.254.0.1"));
|
||||
assert!(is_private_ip("169.254.255.255"));
|
||||
|
||||
// 环回地址 - 应该返回true
|
||||
assert!(is_private_ip("127.0.0.1"));
|
||||
assert!(is_private_ip("localhost"));
|
||||
|
||||
// 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
|
||||
assert!(!is_private_ip("172.0.0.1"));
|
||||
assert!(!is_private_ip("172.15.255.255"));
|
||||
assert!(!is_private_ip("172.32.0.1"));
|
||||
assert!(!is_private_ip("172.64.0.1"));
|
||||
assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
|
||||
assert!(!is_private_ip("172.68.0.1"));
|
||||
assert!(!is_private_ip("172.100.50.25"));
|
||||
assert!(!is_private_ip("172.255.255.255"));
|
||||
|
||||
// 其他公网地址 - 应该返回false
|
||||
assert!(!is_private_ip("8.8.8.8")); // Google DNS
|
||||
assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
|
||||
assert!(!is_private_ip("208.67.222.222")); // OpenDNS
|
||||
assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
|
||||
|
||||
// 域名 - 应该返回false
|
||||
assert!(!is_private_ip("api.example.com"));
|
||||
assert!(!is_private_ip("www.google.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_private_validation() {
|
||||
// IPv6私网地址
|
||||
assert!(is_private_ip("::1")); // 环回地址
|
||||
assert!(is_private_ip("fc00::1")); // 唯一本地地址
|
||||
assert!(is_private_ip("fd00::1")); // 唯一本地地址
|
||||
assert!(is_private_ip("fe80::1")); // 链路本地地址
|
||||
assert!(is_private_ip("::")); // 未指定地址
|
||||
|
||||
// IPv6公网地址 - 应该返回false(修复的重点)
|
||||
assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
|
||||
assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
|
||||
assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
|
||||
assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
|
||||
|
||||
// 测试包含 ::1 子串但不是环回地址的公网地址
|
||||
assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
|
||||
assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hostname_bypass_prevention() {
|
||||
// 看起来像本地,但实际是域名
|
||||
assert!(!is_private_ip("127.0.0.1.evil.com"));
|
||||
assert!(!is_private_ip("localhost.evil.com"));
|
||||
|
||||
// 0.0.0.0 应该被视为本地/阻断
|
||||
assert!(is_private_ip("0.0.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_https_bypass_prevention() {
|
||||
// 非本地域名的 HTTP 应该被拒绝
|
||||
@@ -801,37 +580,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// 边界情况测试
|
||||
assert!(is_private_ip("172.16.0.0")); // RFC1918起始
|
||||
assert!(is_private_ip("172.31.255.255")); // RFC1918结束
|
||||
assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
|
||||
assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
|
||||
assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
|
||||
assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
|
||||
|
||||
// 紧邻RFC1918的公网地址 - 应该返回false
|
||||
assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
|
||||
assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ip_addr_parsing() {
|
||||
// 测试IP地址解析功能
|
||||
let ipv4_private = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(is_private_ip_addr(ipv4_private));
|
||||
|
||||
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(!is_private_ip_addr(ipv4_public));
|
||||
|
||||
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(is_private_ip_addr(ipv6_private));
|
||||
|
||||
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(!is_private_ip_addr(ipv6_public));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_comparison() {
|
||||
// 测试端口比较逻辑是否正确处理默认端口和显式端口
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.12.3",
|
||||
"version": "3.13.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -525,7 +525,7 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_google_official_gemini_sets_oauth_security() {
|
||||
fn switch_google_official_gemini_preserves_env_vars() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
@@ -540,7 +540,9 @@ fn switch_google_official_gemini_sets_oauth_security() {
|
||||
"google-official".to_string(),
|
||||
"Google".to_string(),
|
||||
json!({
|
||||
"env": {}
|
||||
"env": {
|
||||
"GEMINI_MODEL": "gemini-2.5-pro"
|
||||
}
|
||||
}),
|
||||
Some("https://ai.google.dev".to_string()),
|
||||
);
|
||||
@@ -558,23 +560,30 @@ fn switch_google_official_gemini_sets_oauth_security() {
|
||||
ProviderService::switch(&state, AppType::Gemini, "google-official")
|
||||
.expect("switching to Google official Gemini should succeed");
|
||||
|
||||
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
// Verify env vars are preserved in ~/.gemini/.env
|
||||
let env_path = home.join(".gemini").join(".env");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings.json should exist at {}",
|
||||
gemini_settings.display()
|
||||
env_path.exists(),
|
||||
"Gemini .env should exist at {}",
|
||||
env_path.display()
|
||||
);
|
||||
let env_content = std::fs::read_to_string(&env_path).expect("read gemini .env");
|
||||
assert!(
|
||||
env_content.contains("GEMINI_MODEL=gemini-2.5-pro"),
|
||||
"GEMINI_MODEL should be preserved in .env, got: {env_content}"
|
||||
);
|
||||
|
||||
// Verify OAuth security flag is still set correctly
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||
let gemini_value: serde_json::Value =
|
||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
|
||||
|
||||
assert_eq!(
|
||||
gemini_value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("oauth-personal"),
|
||||
"Gemini settings json should reflect oauth-personal for Google Official"
|
||||
"OAuth security flag should still be set"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 0,
|
||||
content_hash: None,
|
||||
updated_at: 0,
|
||||
})
|
||||
.expect("save disabled skill");
|
||||
|
||||
@@ -154,6 +156,8 @@ fn uninstall_skill_creates_backup_before_removing_ssot() {
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 123,
|
||||
content_hash: None,
|
||||
updated_at: 0,
|
||||
})
|
||||
.expect("save skill");
|
||||
|
||||
@@ -222,6 +226,8 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 456,
|
||||
content_hash: None,
|
||||
updated_at: 0,
|
||||
})
|
||||
.expect("save skill");
|
||||
|
||||
@@ -303,6 +309,8 @@ fn delete_skill_backup_removes_backup_directory() {
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 789,
|
||||
content_hash: None,
|
||||
updated_at: 0,
|
||||
})
|
||||
.expect("save skill");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user