mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12b972a66e | |||
| ff3bc242cc |
@@ -1908,25 +1908,53 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
|||||||
|
|
||||||
if let Some(provider_key) = model_provider {
|
if let Some(provider_key) = model_provider {
|
||||||
// Ensure [model_providers] table exists
|
// Ensure [model_providers] table exists
|
||||||
if doc.get("model_providers").is_none() {
|
//
|
||||||
|
// 用 as_table_like_mut 而非 as_table_mut:用户把配置写成 inline table
|
||||||
|
// (`model_providers = { foo = {...} }`,TOML 合法)时 as_table_mut
|
||||||
|
// 返回 None,会一路掉进下面的顶层 fallback——用户改的 base_url 被写到
|
||||||
|
// 了错误层级且毫无提示。
|
||||||
|
if doc
|
||||||
|
.get("model_providers")
|
||||||
|
.is_none_or(|item| item.as_table_like().is_none())
|
||||||
|
{
|
||||||
|
// 键存在但不是表(`model_providers = 42`)时,下面这行会把用户
|
||||||
|
// 手写的值替换掉。旧代码在这种形状下会掉进顶层 fallback 而不动
|
||||||
|
// 它,所以归一化必须留痕——与 mcp/codex.rs、mcp/grokbuild.rs、
|
||||||
|
// opencode_config.rs 的同款处理保持一致。
|
||||||
|
if doc
|
||||||
|
.get("model_providers")
|
||||||
|
.is_some_and(|item| !item.is_none())
|
||||||
|
{
|
||||||
|
log::warn!("config.toml 的 model_providers 不是表,已重置为空表");
|
||||||
|
}
|
||||||
doc["model_providers"] = toml_edit::table();
|
doc["model_providers"] = toml_edit::table();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
|
if let Some(model_providers) = doc
|
||||||
|
.get_mut("model_providers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
{
|
||||||
// Ensure [model_providers.<provider_key>] table exists
|
// Ensure [model_providers.<provider_key>] table exists
|
||||||
if !model_providers.contains_key(&provider_key) {
|
if !model_providers.contains_key(&provider_key) {
|
||||||
model_providers[&provider_key] = toml_edit::table();
|
model_providers.insert(&provider_key, toml_edit::table());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
if let Some(provider_table) = model_providers
|
||||||
|
.get_mut(&provider_key)
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
{
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
provider_table.remove(field);
|
provider_table.remove(field);
|
||||||
} else {
|
} else {
|
||||||
provider_table[field] = toml_edit::value(trimmed);
|
provider_table.insert(field, toml_edit::value(trimmed));
|
||||||
}
|
}
|
||||||
return Ok(doc.to_string());
|
return Ok(doc.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"config.toml 的 [model_providers.{provider_key}] 结构异常,{field} 改写为顶层字段"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: no model_provider or structure mismatch → top-level field
|
// Fallback: no model_provider or structure mismatch → top-level field
|
||||||
@@ -2585,6 +2613,34 @@ model = "gpt-4"
|
|||||||
assert_eq!(base_url, "https://fallback.api/v1");
|
assert_eq!(base_url, "https://fallback.api/v1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_url_writes_into_inline_table_provider_section() {
|
||||||
|
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None。旧代码会因此
|
||||||
|
// 掉进「写顶层字段」的 fallback:用户改的 base_url 落在错误层级,
|
||||||
|
// Codex 读不到,且界面毫无提示。
|
||||||
|
let input = r#"model_provider = "any"
|
||||||
|
model_providers = { any = { name = "any", base_url = "https://old.api/v1", wire_api = "responses" } }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||||
|
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed["model_providers"]["any"]["base_url"].as_str(),
|
||||||
|
Some("https://new.api/v1"),
|
||||||
|
"must update the provider section, not a top-level field"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed.get("base_url").is_none(),
|
||||||
|
"must not leak a top-level base_url fallback"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed["model_providers"]["any"]["wire_api"].as_str(),
|
||||||
|
Some("responses"),
|
||||||
|
"sibling fields must survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clearing_base_url_removes_only_from_correct_section() {
|
fn clearing_base_url_removes_only_from_correct_section() {
|
||||||
let input = r#"model_provider = "any"
|
let input = r#"model_provider = "any"
|
||||||
|
|||||||
@@ -301,6 +301,10 @@ pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>,
|
|||||||
/// 添加技能仓库
|
/// 添加技能仓库
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
// 整个结构体由前端反序列化而来,owner/name/branch 会被拼进归档下载 URL。
|
||||||
|
// 主防线在 download_repo,这里让非法值当场报错而不是沉淀进表。
|
||||||
|
SkillService::validate_repo_ref(&repo.owner, &repo.name, &repo.branch)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.save_skill_repo(&repo)
|
.save_skill_repo(&repo)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//! 使用统计相关命令
|
//! 使用统计相关命令
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::services::model_pricing::{ModelPricingInfo, ModelsDevSyncConfig, ModelsDevSyncState};
|
||||||
use crate::services::usage_stats::*;
|
use crate::services::usage_stats::*;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
/// 获取使用量汇总
|
/// 获取使用量汇总
|
||||||
@@ -125,6 +124,7 @@ pub fn get_request_detail(
|
|||||||
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
||||||
log::info!("获取模型定价列表");
|
log::info!("获取模型定价列表");
|
||||||
state.db.ensure_model_pricing_seeded()?;
|
state.db.ensure_model_pricing_seeded()?;
|
||||||
|
crate::services::model_pricing::sync_local_model_pricing(&state.db)?;
|
||||||
|
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
@@ -181,72 +181,53 @@ pub fn update_model_pricing(
|
|||||||
cache_read_cost: String,
|
cache_read_cost: String,
|
||||||
cache_creation_cost: String,
|
cache_creation_cost: String,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let db = state.db.clone();
|
crate::services::model_pricing::update_model_pricing(
|
||||||
let model_id = model_id.trim().to_string();
|
&state.db,
|
||||||
let display_name = display_name.trim().to_string();
|
ModelPricingInfo {
|
||||||
if model_id.is_empty() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage.modelIdRequired",
|
|
||||||
"模型 ID 不能为空",
|
|
||||||
"Model ID is required",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if display_name.is_empty() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage.displayNameRequired",
|
|
||||||
"显示名称不能为空",
|
|
||||||
"Display name is required",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (label, value) in [
|
|
||||||
("input_cost", &input_cost),
|
|
||||||
("output_cost", &output_cost),
|
|
||||||
("cache_read_cost", &cache_read_cost),
|
|
||||||
("cache_creation_cost", &cache_creation_cost),
|
|
||||||
] {
|
|
||||||
let parsed = Decimal::from_str(value.trim()).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage.invalidPrice",
|
|
||||||
format!("{label} 价格无效: {value} - {e}"),
|
|
||||||
format!("{label} price is invalid: {value} - {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if parsed < Decimal::ZERO {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage.invalidPrice",
|
|
||||||
format!("{label} 价格必须为非负数: {value}"),
|
|
||||||
format!("{label} price must be non-negative: {value}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE 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,
|
model_id,
|
||||||
display_name,
|
display_name,
|
||||||
input_cost.trim(),
|
input_cost_per_million: input_cost,
|
||||||
output_cost.trim(),
|
output_cost_per_million: output_cost,
|
||||||
cache_read_cost.trim(),
|
cache_read_cost_per_million: cache_read_cost,
|
||||||
cache_creation_cost.trim()
|
cache_creation_cost_per_million: cache_creation_cost,
|
||||||
],
|
},
|
||||||
)
|
)?;
|
||||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = db.backfill_missing_usage_costs_for_model(&model_id) {
|
|
||||||
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 批量更新模型定价(models.dev 自动同步仅触发一次历史成本回填)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_model_pricing_batch(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
entries: Vec<ModelPricingInfo>,
|
||||||
|
) -> Result<usize, AppError> {
|
||||||
|
crate::services::model_pricing::update_model_pricing_batch(&state.db, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_models_dev_sync_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ModelsDevSyncState, AppError> {
|
||||||
|
crate::services::model_pricing::get_models_dev_sync_state(&state.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_models_dev_sync_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: ModelsDevSyncConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
crate::services::model_pricing::save_models_dev_sync_config(&state.db, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn record_models_dev_sync_result(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
synced_at: Option<i64>,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
crate::services::model_pricing::record_models_dev_sync_result(&state.db, synced_at, error)
|
||||||
|
}
|
||||||
|
|
||||||
/// 检查 Provider 使用限额
|
/// 检查 Provider 使用限额
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn check_provider_limits(
|
pub fn check_provider_limits(
|
||||||
@@ -260,15 +241,7 @@ pub fn check_provider_limits(
|
|||||||
/// 删除模型定价
|
/// 删除模型定价
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
||||||
let db = state.db.clone();
|
crate::services::model_pricing::delete_model_pricing(&state.db, &model_id)?;
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM model_pricing WHERE model_id = ?1",
|
|
||||||
rusqlite::params![model_id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
|
||||||
|
|
||||||
log::info!("已删除模型定价: {model_id}");
|
log::info!("已删除模型定价: {model_id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -326,18 +299,6 @@ pub fn get_usage_data_sources(
|
|||||||
crate::services::session_usage::get_data_source_breakdown(&state.db)
|
crate::services::session_usage::get_data_source_breakdown(&state.db)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模型定价信息
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ModelPricingInfo {
|
|
||||||
pub model_id: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub input_cost_per_million: String,
|
|
||||||
pub output_cost_per_million: String,
|
|
||||||
pub cache_read_cost_per_million: String,
|
|
||||||
pub cache_creation_cost_per_million: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ impl Database {
|
|||||||
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
||||||
}
|
}
|
||||||
db.ensure_model_pricing_seeded()?;
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
if let Err(e) = crate::services::model_pricing::sync_local_model_pricing(&db) {
|
||||||
|
log::warn!("Failed to sync local model pricing file: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
// Startup cleanup: prune old logs and reclaim space
|
// Startup cleanup: prune old logs and reclaim space
|
||||||
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
|
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
|
||||||
|
|||||||
@@ -832,9 +832,34 @@ fn merge_grokbuild_config(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(|value| value.is_empty())
|
.is_none_or(|value| value.is_empty())
|
||||||
{
|
{
|
||||||
request.api_key = model.api_key.or_else(|| {
|
// Only inline an explicitly-declared `api_key`. Do NOT resolve `env_key`
|
||||||
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
|
// (or any process env var) into a plaintext value here: a deeplink is
|
||||||
});
|
// untrusted input, and resolving+inlining would silently persist the
|
||||||
|
// victim's environment secret into the imported provider's config.toml
|
||||||
|
// and ship it to whatever `base_url` the link declares. `env_key` is an
|
||||||
|
// indirection that must stay a name, not a resolved secret, on import.
|
||||||
|
request.api_key = model.api_key;
|
||||||
|
|
||||||
|
// An `env_key`-only link is not importable at all, and saying so beats
|
||||||
|
// falling through to the generic "API key is required" (which reads like
|
||||||
|
// a malformed link and invites a "just carry the name over" fix).
|
||||||
|
// Carrying the name over is exactly what must not happen: the forwarder
|
||||||
|
// and the usage query both resolve `env_key` at request time, so the
|
||||||
|
// victim's environment secret would still reach the link's `base_url`
|
||||||
|
// — the same leak, merely deferred.
|
||||||
|
if request
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|value| value.is_empty())
|
||||||
|
&& model.env_key.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"This link supplies its API key indirectly through `env_key`, which cannot be \
|
||||||
|
imported from an untrusted link. Add the provider manually and enter the key \
|
||||||
|
yourself."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if request
|
if request
|
||||||
.endpoint
|
.endpoint
|
||||||
@@ -929,6 +954,112 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// deeplink 同时声明 `api_key` 与 `env_key` 时,导入结果只保留用户可见的
|
||||||
|
/// `api_key`:既不能带上解析后的明文环境变量,也不能把 `env_key` 这个间接
|
||||||
|
/// 引用本身写进供应商配置。
|
||||||
|
///
|
||||||
|
/// 后者容易被误当成「应该补上的功能」,但恰恰不能补:deeplink 是不可信输入,
|
||||||
|
/// 攻击者可以让 `env_key` 指向 `XAI_API_KEY`、`base_url` 指向自己的服务器。
|
||||||
|
/// 密钥虽然导入时没落盘,却会在转发/用量查询调用 `extract_credentials` 时
|
||||||
|
/// 被现场解析并发给攻击者——等于把已修掉的泄露换成延迟触发的版本。
|
||||||
|
/// 手工建供应商的表单路径不受影响,那里的 env_key 是用户自己输入的。
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn grokbuild_deeplink_never_carries_env_key_or_resolved_secret() {
|
||||||
|
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||||||
|
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||||||
|
|
||||||
|
let mut request = DeepLinkImportRequest {
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("grokbuild".to_string()),
|
||||||
|
name: Some("Attacker".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let config = serde_json::json!({
|
||||||
|
"config": concat!(
|
||||||
|
"[models]\ndefault = \"grok-env\"\n\n",
|
||||||
|
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||||||
|
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||||||
|
"api_key = \"sk-declared-by-link\"\n",
|
||||||
|
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||||||
|
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
merge_grokbuild_config(&mut request, &config).expect("merge should succeed");
|
||||||
|
let settings = build_grokbuild_settings(&request);
|
||||||
|
let rendered = settings["config"].as_str().expect("config string");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("secret-must-not-leak"),
|
||||||
|
"the environment secret must never be inlined: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("env_key"),
|
||||||
|
"the env_key indirection must not be carried over from an untrusted link: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("api_key = \"sk-declared-by-link\""),
|
||||||
|
"only the explicitly declared api_key should survive: {rendered}"
|
||||||
|
);
|
||||||
|
|
||||||
|
match original {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `env_key` 独苗的链接必须在**公开入口**上被明确拒绝。
|
||||||
|
///
|
||||||
|
/// 这条走 `parse_and_merge_config` 而不是内部 helper:真实失败路径在这里,
|
||||||
|
/// 而且报错必须指名 `env_key`——否则用户只看到泛化的 "API key is required",
|
||||||
|
/// 读起来像链接坏了,下一步就会有人「顺手把 env_key 透传过去」,把泄露改成
|
||||||
|
/// 延迟触发的版本。
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn grokbuild_env_key_only_link_is_rejected_at_the_public_entry() {
|
||||||
|
use base64::prelude::*;
|
||||||
|
|
||||||
|
// 探针变量必须**真的设上**。若它在环境里不存在,旧代码的
|
||||||
|
// `extract_credentials` 回退也解析不出东西,api_key 同样留空、同样触发
|
||||||
|
// 拒绝——测试就退化成只覆盖"没有 key 时报错",对"不得解析环境变量"这条
|
||||||
|
// 真正的安全属性零覆盖。设上之后,一旦有人恢复回退解析,api_key 会变成
|
||||||
|
// 非空、拒绝不再触发,这条断言立刻变红。
|
||||||
|
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||||||
|
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||||||
|
|
||||||
|
let config_toml = concat!(
|
||||||
|
"[models]\ndefault = \"grok-env\"\n\n",
|
||||||
|
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||||||
|
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||||||
|
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||||||
|
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||||||
|
);
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("grokbuild".to_string()),
|
||||||
|
name: Some("Attacker".to_string()),
|
||||||
|
config: Some(BASE64_STANDARD.encode(config_toml)),
|
||||||
|
config_format: Some("toml".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = parse_and_merge_config(&request);
|
||||||
|
|
||||||
|
match original {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = result.expect_err(
|
||||||
|
"an env_key-only link must not be importable, and its env var must never be resolved",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("env_key"),
|
||||||
|
"the rejection must name env_key so it is not mistaken for a malformed link: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_hermes_settings_emits_snake_case() {
|
fn build_hermes_settings_emits_snake_case() {
|
||||||
let settings = build_hermes_settings(&hermes_request());
|
let settings = build_hermes_settings(&hermes_request());
|
||||||
|
|||||||
@@ -33,12 +33,25 @@ pub fn import_skill_from_deeplink(
|
|||||||
}
|
}
|
||||||
let owner = parts[0].to_string();
|
let owner = parts[0].to_string();
|
||||||
let name = parts[1].to_string();
|
let name = parts[1].to_string();
|
||||||
|
let branch = request.branch.unwrap_or_else(|| "main".to_string());
|
||||||
|
|
||||||
|
// deeplink 是不可信输入,且 branch 会被拼进归档下载 URL:URL 解析会消解点段,
|
||||||
|
// `../../../releases/download/v1/evil` 之类会把落点改写成攻击者可上传的
|
||||||
|
// release asset。主防线在 SkillService::download_repo,这里是入库前的纵深拦截,
|
||||||
|
// 让用户当场看到错误而不是让脏数据沉淀进 skill_repos 表。
|
||||||
|
crate::services::skill::SkillService::validate_repo_ref(&owner, &name, &branch).map_err(
|
||||||
|
|_| {
|
||||||
|
AppError::InvalidInput(format!(
|
||||||
|
"Invalid skill repository reference: '{owner}/{name}' branch '{branch}'"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
// Create SkillRepo
|
// Create SkillRepo
|
||||||
let repo = SkillRepo {
|
let repo = SkillRepo {
|
||||||
owner: owner.clone(),
|
owner: owner.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
branch: request.branch.unwrap_or_else(|| "main".to_string()),
|
branch,
|
||||||
enabled: request.enabled.unwrap_or(true),
|
enabled: request.enabled.unwrap_or(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,71 @@ pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
|||||||
Ok(parse_env_file(&content))
|
Ok(parse_env_file(&content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 .env 原文中按「键名 + 值」双匹配删除若干行,其余内容逐字保留
|
||||||
|
///
|
||||||
|
/// 不走 `parse_env_file` → `serialize_env_file` 的往返:那对函数会丢掉注释、空行、
|
||||||
|
/// 无法识别的行和重复定义,并按键名重排整个文件。全量投影时这无所谓(本来就要重写
|
||||||
|
/// 整份),但用来做**定向**清理就等于顺手把用户手写的东西一起删了。
|
||||||
|
///
|
||||||
|
/// 按值匹配而非按键名:只清掉扩散出去的那一份,用户自己写的同名不同值的行保留。
|
||||||
|
/// 同一个键有重复定义时也只删命中的那条,被它遮住的上一条会重新生效——这正是想要的
|
||||||
|
/// 结果,因为遮住它的恰恰是泄漏值。
|
||||||
|
///
|
||||||
|
/// 返回 `None` 表示没有任何一行命中,调用方据此跳过写盘。
|
||||||
|
pub fn remove_env_entries_preserving_layout(
|
||||||
|
content: &str,
|
||||||
|
doomed: &HashMap<String, String>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut removed = false;
|
||||||
|
let mut kept: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
for line in content.split('\n') {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
let hit = !trimmed.is_empty()
|
||||||
|
&& !trimmed.starts_with('#')
|
||||||
|
&& trimmed.split_once('=').is_some_and(|(key, value)| {
|
||||||
|
doomed
|
||||||
|
.get(key.trim())
|
||||||
|
.is_some_and(|doomed_value| doomed_value == value.trim())
|
||||||
|
});
|
||||||
|
|
||||||
|
if hit {
|
||||||
|
removed = true;
|
||||||
|
} else {
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed.then(|| kept.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `~/.gemini/.env` 中定向删除「键=值」完全匹配的行,返回是否真的改了文件
|
||||||
|
pub fn remove_gemini_env_entries(doomed: &HashMap<String, String>) -> Result<bool, AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
match remove_env_entries_preserving_layout(&content, doomed) {
|
||||||
|
Some(cleaned) => {
|
||||||
|
write_gemini_env_text_atomic(&cleaned)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 写入 Gemini .env 文件(原子操作)
|
/// 写入 Gemini .env 文件(原子操作)
|
||||||
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
||||||
|
write_gemini_env_text_atomic(&serialize_env_file(map))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 Gemini .env 文件(原子操作,内容逐字落盘)
|
||||||
|
///
|
||||||
|
/// 与 `write_gemini_env_atomic` 共用目录/文件权限处理,区别只在于内容不经
|
||||||
|
/// `serialize_env_file` 归一化——供保序的定向删除使用。
|
||||||
|
pub fn write_gemini_env_text_atomic(content: &str) -> Result<(), AppError> {
|
||||||
let path = get_gemini_env_path();
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
@@ -172,8 +235,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = serialize_env_file(map);
|
write_text_file(&path, content)?;
|
||||||
write_text_file(&path, &content)?;
|
|
||||||
|
|
||||||
// 设置文件权限为 600(仅所有者可读写)
|
// 设置文件权限为 600(仅所有者可读写)
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
@@ -209,21 +209,22 @@ pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
|
|||||||
|
|
||||||
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
||||||
let config = extract_model_config(config_toml)?;
|
let config = extract_model_config(config_toml)?;
|
||||||
let api_key = config
|
// Credentials only come from two explicit, config-declared sources:
|
||||||
.api_key
|
// 1. an inline `api_key`, or
|
||||||
.or_else(|| {
|
// 2. the process env var named by `env_key`.
|
||||||
|
//
|
||||||
|
// Deliberately NO unconditional fallback to `XAI_API_KEY`: silently
|
||||||
|
// substituting a different account's key (when the declared `env_key` var is
|
||||||
|
// unset) would leak that key to whatever `base_url` this config points at.
|
||||||
|
// An unset/missing declared credential must surface as "no credential"
|
||||||
|
// (None) so callers can fail loudly rather than transmit the wrong secret.
|
||||||
|
let api_key = config.api_key.or_else(|| {
|
||||||
config
|
config
|
||||||
.env_key
|
.env_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|key| std::env::var(key).ok())
|
.and_then(|key| std::env::var(key).ok())
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
std::env::var("XAI_API_KEY")
|
|
||||||
.ok()
|
|
||||||
.map(|value| value.trim().to_string())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
})?;
|
})?;
|
||||||
Some((config.base_url, api_key))
|
Some((config.base_url, api_key))
|
||||||
}
|
}
|
||||||
@@ -540,6 +541,48 @@ context_window = 500000
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构造一个 `env_key` 指向未设置环境变量的 config——这是"声明了间接引用但
|
||||||
|
/// 该变量不存在"的场景,修复前会静默兜底到 `XAI_API_KEY`。
|
||||||
|
fn env_key_unset_config() -> &'static str {
|
||||||
|
r#"[models]
|
||||||
|
default = "grok-env"
|
||||||
|
|
||||||
|
[model."grok-env"]
|
||||||
|
model = "grok-4.5"
|
||||||
|
base_url = "https://attacker.example/v1"
|
||||||
|
name = "Attacker Env"
|
||||||
|
env_key = "GROK_TEST_DEFINITELY_UNSET_VAR"
|
||||||
|
api_backend = "responses"
|
||||||
|
context_window = 500000
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn does_not_fall_back_to_xai_api_key_when_declared_env_key_is_unset() {
|
||||||
|
// 即使进程里恰好设了 XAI_API_KEY,也不能被静默借用到别的 base_url 上。
|
||||||
|
let original_xai = std::env::var_os("XAI_API_KEY");
|
||||||
|
let original_unset = std::env::var_os("GROK_TEST_DEFINITELY_UNSET_VAR");
|
||||||
|
std::env::set_var("XAI_API_KEY", "xai-secret-should-not-leak");
|
||||||
|
std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR");
|
||||||
|
|
||||||
|
let credentials = extract_credentials(env_key_unset_config());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
credentials.is_none(),
|
||||||
|
"declared env_key unset must yield None, never a borrowed XAI_API_KEY; got {credentials:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
match original_xai {
|
||||||
|
Some(value) => std::env::set_var("XAI_API_KEY", value),
|
||||||
|
None => std::env::remove_var("XAI_API_KEY"),
|
||||||
|
}
|
||||||
|
match original_unset {
|
||||||
|
Some(value) => std::env::set_var("GROK_TEST_DEFINITELY_UNSET_VAR", value),
|
||||||
|
None => std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strips_projected_mcp_servers_without_touching_model_config() {
|
fn strips_projected_mcp_servers_without_touching_model_config() {
|
||||||
let mut settings = json!({
|
let mut settings = json!({
|
||||||
|
|||||||
@@ -1184,6 +1184,17 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 必须排在 auto-extract 之前:先把历史泄漏进 Gemini 共享片段的凭据
|
||||||
|
// 清干净,否则紧接着的提取会基于被污染的 live 再写一遍。
|
||||||
|
if let Err(e) =
|
||||||
|
crate::services::provider::ProviderService::scrub_leaked_gemini_common_config(
|
||||||
|
&state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("清理 Gemini 通用配置泄漏凭据失败: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
initialize_common_config_snippets(&state);
|
initialize_common_config_snippets(&state);
|
||||||
|
|
||||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
@@ -1521,7 +1532,11 @@ pub fn run() {
|
|||||||
commands::get_request_detail,
|
commands::get_request_detail,
|
||||||
commands::get_model_pricing,
|
commands::get_model_pricing,
|
||||||
commands::update_model_pricing,
|
commands::update_model_pricing,
|
||||||
|
commands::update_model_pricing_batch,
|
||||||
commands::delete_model_pricing,
|
commands::delete_model_pricing,
|
||||||
|
commands::get_models_dev_sync_config,
|
||||||
|
commands::save_models_dev_sync_config,
|
||||||
|
commands::record_models_dev_sync_result,
|
||||||
commands::check_provider_limits,
|
commands::check_provider_limits,
|
||||||
// Session usage sync
|
// Session usage sync
|
||||||
commands::sync_session_usage,
|
commands::sync_session_usage,
|
||||||
|
|||||||
+153
-22
@@ -348,6 +348,74 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
|||||||
|
|
||||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||||
|
/// 把单个 MCP server 表写入 `[mcp_servers]`,并保证该键是「表」。
|
||||||
|
///
|
||||||
|
/// `~/.codex/config.toml` 是用户可手改的:若 `mcp_servers` 存在但不是表
|
||||||
|
/// (如 `mcp_servers = "x"` / `[]`),仅判 `contains_key` 会跳过重建,随后的
|
||||||
|
/// `doc["mcp_servers"][id] = …` 会触发 toml_edit 的 `IndexMut` panic
|
||||||
|
/// (panic 发生在 Tauri command 内、跨 FFI 展开)。这里统一归一化后再插入。
|
||||||
|
fn upsert_mcp_server_table(
|
||||||
|
doc: &mut toml_edit::DocumentMut,
|
||||||
|
id: &str,
|
||||||
|
table: toml_edit::Table,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if doc
|
||||||
|
.get_mut("mcp_servers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
// 键存在但不是表时,归一化会丢掉用户手写的那个值——必须留痕,
|
||||||
|
// 否则用户只会看到自己的改动凭空消失。
|
||||||
|
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||||
|
log::warn!("config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||||
|
}
|
||||||
|
doc["mcp_servers"] = toml_edit::table();
|
||||||
|
}
|
||||||
|
let servers = doc
|
||||||
|
.get_mut("mcp_servers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
.ok_or_else(|| AppError::McpValidation("config.toml 的 mcp_servers 不是表".to_string()))?;
|
||||||
|
servers.insert(id, toml_edit::Item::Table(table));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `[mcp_servers]`(以及历史错误格式 `[mcp.servers]`)中删除单个 MCP server。
|
||||||
|
///
|
||||||
|
/// 与 `upsert_mcp_server_table` 对称地使用 `as_table_like_mut`:用户若把配置写成
|
||||||
|
/// inline table(`mcp_servers = { foo = {...} }`,TOML 合法),`as_table_mut` 会返回
|
||||||
|
/// None 导致删除**静默失效**——界面提示已移除,条目却还在文件里,Codex 下次启动照样
|
||||||
|
/// 加载。这比 panic 更隐蔽,因为用户往往正是发现某个 MCP 有问题才来关它的。
|
||||||
|
///
|
||||||
|
/// 与写入分离成纯 doc 级函数,使守卫可脱离真实 `~/.codex/config.toml` 单测。
|
||||||
|
fn remove_mcp_server_from_doc(doc: &mut toml_edit::DocumentMut, id: &str) {
|
||||||
|
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||||
|
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||||
|
// 必须在取可变借用之前算出来。
|
||||||
|
let user_authored = !item.is_none();
|
||||||
|
match item.as_table_like_mut() {
|
||||||
|
Some(mcp_servers) => {
|
||||||
|
mcp_servers.remove(id);
|
||||||
|
}
|
||||||
|
None if user_authored => {
|
||||||
|
log::warn!("config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||||
|
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_like_mut()) {
|
||||||
|
if let Some(servers) = mcp_table
|
||||||
|
.get_mut("servers")
|
||||||
|
.and_then(|s| s.as_table_like_mut())
|
||||||
|
{
|
||||||
|
if servers.remove(id).is_some() {
|
||||||
|
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sync_single_server_to_codex(
|
pub fn sync_single_server_to_codex(
|
||||||
_config: &MultiAppConfig,
|
_config: &MultiAppConfig,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -356,7 +424,6 @@ pub fn sync_single_server_to_codex(
|
|||||||
if !should_sync_codex_mcp() {
|
if !should_sync_codex_mcp() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
use toml_edit::Item;
|
|
||||||
|
|
||||||
// 读取现有的 config.toml
|
// 读取现有的 config.toml
|
||||||
let config_path = crate::codex_config::get_codex_config_path();
|
let config_path = crate::codex_config::get_codex_config_path();
|
||||||
@@ -383,16 +450,9 @@ pub fn sync_single_server_to_codex(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 [mcp_servers] 表存在
|
|
||||||
if !doc.contains_key("mcp_servers") {
|
|
||||||
doc["mcp_servers"] = toml_edit::table();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 JSON 服务器规范转换为 TOML 表
|
// 将 JSON 服务器规范转换为 TOML 表
|
||||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||||
|
upsert_mcp_server_table(&mut doc, id, toml_table)?;
|
||||||
// 使用唯一正确的格式:[mcp_servers]
|
|
||||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
let new_text = doc.to_string();
|
let new_text = doc.to_string();
|
||||||
@@ -425,19 +485,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从正确的位置删除:[mcp_servers]
|
remove_mcp_server_from_doc(&mut doc, id);
|
||||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
|
||||||
mcp_servers.remove(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
|
||||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
|
||||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
|
||||||
if servers.remove(id).is_some() {
|
|
||||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
let new_text = doc.to_string();
|
let new_text = doc.to_string();
|
||||||
@@ -683,6 +731,89 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_normalizes_non_table_mcp_servers_without_panicking() {
|
||||||
|
// 用户手改过的 config.toml:mcp_servers 是字符串而不是表。
|
||||||
|
// 修复前 `doc["mcp_servers"][id] = …` 会 panic。
|
||||||
|
for malformed in [
|
||||||
|
"mcp_servers = \"x\"\n",
|
||||||
|
"mcp_servers = []\n",
|
||||||
|
"mcp_servers = 42\n",
|
||||||
|
] {
|
||||||
|
let mut doc = malformed
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
let table = json_server_to_toml_table(&json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx"
|
||||||
|
}))
|
||||||
|
.expect("server table");
|
||||||
|
|
||||||
|
upsert_mcp_server_table(&mut doc, "echo", table)
|
||||||
|
.unwrap_or_else(|e| panic!("upsert must not fail for {malformed:?}: {e}"));
|
||||||
|
|
||||||
|
let servers = doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.and_then(|item| item.as_table_like())
|
||||||
|
.unwrap_or_else(|| panic!("mcp_servers must be normalized to a table"));
|
||||||
|
assert!(servers.contains_key("echo"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_preserves_existing_servers_in_a_valid_table() {
|
||||||
|
let mut doc = "[mcp_servers.keep]\ncommand = \"keep\"\n"
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
let table = json_server_to_toml_table(&json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx"
|
||||||
|
}))
|
||||||
|
.expect("server table");
|
||||||
|
|
||||||
|
upsert_mcp_server_table(&mut doc, "added", table).expect("upsert");
|
||||||
|
|
||||||
|
let servers = doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.and_then(|item| item.as_table_like())
|
||||||
|
.expect("table");
|
||||||
|
assert!(servers.contains_key("keep"), "existing server must survive");
|
||||||
|
assert!(servers.contains_key("added"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_deletes_from_inline_table_form_too() {
|
||||||
|
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None——用它做守卫
|
||||||
|
// 会让删除静默失效:界面说移除成功,条目却还在,Codex 下次启动照样加载。
|
||||||
|
let mut doc = "mcp_servers = { drop = { command = \"x\" }, keep = { command = \"y\" } }\n"
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
|
||||||
|
remove_mcp_server_from_doc(&mut doc, "drop");
|
||||||
|
|
||||||
|
let servers = doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.and_then(|item| item.as_table_like())
|
||||||
|
.expect("mcp_servers must still be table-like");
|
||||||
|
assert!(
|
||||||
|
!servers.contains_key("drop"),
|
||||||
|
"removal must work on the inline-table form"
|
||||||
|
);
|
||||||
|
assert!(servers.contains_key("keep"), "siblings must survive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_is_a_noop_on_non_table_mcp_servers() {
|
||||||
|
// 既不能 panic,也不能把用户手写的值悄悄抹掉
|
||||||
|
let mut doc = "mcp_servers = 42\n"
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
|
||||||
|
remove_mcp_server_from_doc(&mut doc, "whatever");
|
||||||
|
|
||||||
|
assert_eq!(doc.to_string(), "mcp_servers = 42\n");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn http_headers_are_only_written_to_codex_http_headers() {
|
fn http_headers_are_only_written_to_codex_http_headers() {
|
||||||
let table = json_server_to_toml_table(&json!({
|
let table = json_server_to_toml_table(&json!({
|
||||||
|
|||||||
@@ -148,10 +148,30 @@ pub fn sync_single_server_to_grokbuild(
|
|||||||
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
if !doc.contains_key("mcp_servers") {
|
// 若 mcp_servers 缺失或存在但不是 table(如 `mcp_servers = "x"` / `[]`),
|
||||||
|
// 用空 table 归一化,避免后续 `doc["mcp_servers"][id] = …` 对非 table 索引
|
||||||
|
// 触发 toml_edit 的 IndexMut panic。用户手写的 config.toml 不可信。
|
||||||
|
// 判定走不可变的 `as_table_like`:借可变引用只为判空,会逼着下面再 get_mut 一次。
|
||||||
|
if doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.is_none_or(|item| item.as_table_like().is_none())
|
||||||
|
{
|
||||||
|
// 归一化会丢掉用户手写的那个非表值,必须留痕。
|
||||||
|
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||||
|
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||||
|
}
|
||||||
doc["mcp_servers"] = toml_edit::table();
|
doc["mcp_servers"] = toml_edit::table();
|
||||||
}
|
}
|
||||||
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
|
let servers = doc
|
||||||
|
.get_mut("mcp_servers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::McpValidation("Grok Build config.toml 的 mcp_servers 不是表".to_string())
|
||||||
|
})?;
|
||||||
|
servers.insert(
|
||||||
|
id,
|
||||||
|
Item::Table(json_server_to_grokbuild_toml_table(server_spec)?),
|
||||||
|
);
|
||||||
crate::config::write_text_file(&path, &doc.to_string())
|
crate::config::write_text_file(&path, &doc.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,12 +191,22 @@ pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(servers) = doc
|
// 与写入侧对称使用 as_table_like_mut:inline table 形态下 as_table_mut 返回
|
||||||
.get_mut("mcp_servers")
|
// None,删除会静默失效——界面显示已移除,Grok Build 下次启动仍会加载它。
|
||||||
.and_then(toml_edit::Item::as_table_mut)
|
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||||
{
|
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||||
|
// 必须在取可变借用之前算出来。
|
||||||
|
let user_authored = !item.is_none();
|
||||||
|
match item.as_table_like_mut() {
|
||||||
|
Some(servers) => {
|
||||||
servers.remove(id);
|
servers.remove(id);
|
||||||
}
|
}
|
||||||
|
None if user_authored => {
|
||||||
|
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::config::write_text_file(&path, &doc.to_string())
|
crate::config::write_text_file(&path, &doc.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,12 +95,27 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
json5::from_str(&content).map_err(|e| {
|
let value: Value = json5::from_str(&content).map_err(|e| {
|
||||||
AppError::Config(format!(
|
AppError::Config(format!(
|
||||||
"Failed to parse OpenCode config: {}: {e}",
|
"Failed to parse OpenCode config: {}: {e}",
|
||||||
path.display()
|
path.display()
|
||||||
))
|
))
|
||||||
})
|
})?;
|
||||||
|
|
||||||
|
// 根节点必须是对象:下游 set_provider / set_mcp_server / add_plugin 都对它做
|
||||||
|
// `config["key"] = …` 索引赋值,而 serde_json 只把 Null 自动升级成对象,
|
||||||
|
// 数组或标量会直接 panic(panic 发生在 Tauri command 内、跨 FFI 展开)。
|
||||||
|
//
|
||||||
|
// 这里选择报错而不是重建根节点:opencode.json 里还有 model / theme 等用户自有
|
||||||
|
// 配置,静默重建等于删掉它们。让用户自己修文件,与 read_claude_live 的做法一致。
|
||||||
|
if !value.is_object() {
|
||||||
|
return Err(AppError::Config(format!(
|
||||||
|
"OpenCode 配置文件根节点必须是 JSON 对象: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||||
@@ -123,7 +138,13 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
|||||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
if full_config.get("provider").is_none() {
|
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到,
|
||||||
|
// 写入会静默失效——界面显示添加成功而文件里没有。provider 段是 cc-switch
|
||||||
|
// 的投影区,归一化不会碰用户自有的 model / theme 等顶层配置。
|
||||||
|
if !full_config.get("provider").is_some_and(Value::is_object) {
|
||||||
|
if full_config.get("provider").is_some() {
|
||||||
|
log::warn!("opencode.json 的 provider 不是对象,已重置为空对象");
|
||||||
|
}
|
||||||
full_config["provider"] = json!({});
|
full_config["provider"] = json!({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +163,8 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||||
providers.remove(id);
|
providers.remove(id);
|
||||||
|
} else if config.get("provider").is_some() {
|
||||||
|
log::warn!("opencode.json 的 provider 不是对象,无法删除供应商 '{id}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
@@ -182,7 +205,10 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
|||||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
if full_config.get("mcp").is_none() {
|
if !full_config.get("mcp").is_some_and(Value::is_object) {
|
||||||
|
if full_config.get("mcp").is_some() {
|
||||||
|
log::warn!("opencode.json 的 mcp 不是对象,已重置为空对象");
|
||||||
|
}
|
||||||
full_config["mcp"] = json!({});
|
full_config["mcp"] = json!({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +224,8 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||||
mcp.remove(id);
|
mcp.remove(id);
|
||||||
|
} else if config.get("mcp").is_some() {
|
||||||
|
log::warn!("opencode.json 的 mcp 不是对象,无法删除服务器 '{id}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
@@ -265,3 +293,77 @@ pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
|||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct TestHomeGuard(Option<std::ffi::OsString>);
|
||||||
|
impl TestHomeGuard {
|
||||||
|
fn set(home: &std::path::Path) -> Self {
|
||||||
|
let guard = Self(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||||
|
std::env::set_var("CC_SWITCH_TEST_HOME", home);
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TestHomeGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match self.0.take() {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_config(home: &std::path::Path, content: &str) {
|
||||||
|
let dir = home.join(".config").join("opencode");
|
||||||
|
std::fs::create_dir_all(&dir).expect("create config dir");
|
||||||
|
std::fs::write(dir.join("opencode.json"), content).expect("write config");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn read_rejects_non_object_root_instead_of_panicking_downstream() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _guard = TestHomeGuard::set(temp.path());
|
||||||
|
|
||||||
|
// 顶层数组/标量会让下游 `config["provider"] = …` 触发 serde_json panic。
|
||||||
|
// 顶层 null 例外——serde_json 会把它自动升级成对象,本来就不炸。
|
||||||
|
for malformed in ["[]", "[{\"a\":1}]", "42", "\"oops\""] {
|
||||||
|
write_config(temp.path(), malformed);
|
||||||
|
let result = read_opencode_config();
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"non-object root must be rejected: {malformed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_config(temp.path(), "{\"model\": \"x\"}");
|
||||||
|
assert!(
|
||||||
|
read_opencode_config().is_ok(),
|
||||||
|
"a normal object config must still load"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn set_mcp_server_normalizes_non_object_section() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _guard = TestHomeGuard::set(temp.path());
|
||||||
|
|
||||||
|
// `"mcp": []` 时旧代码的 as_object_mut 返回 None → 写入静默失效
|
||||||
|
write_config(temp.path(), "{\"model\": \"keep-me\", \"mcp\": []}");
|
||||||
|
|
||||||
|
set_mcp_server("echo", json!({"command": "npx"})).expect("set must succeed");
|
||||||
|
|
||||||
|
let config = read_opencode_config().expect("reload");
|
||||||
|
assert_eq!(
|
||||||
|
config["mcp"]["echo"]["command"], "npx",
|
||||||
|
"server must actually be written"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config["model"], "keep-me",
|
||||||
|
"unrelated user config must be preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,11 +169,23 @@ impl Provider {
|
|||||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||||
(base_url, api_key)
|
(base_url, api_key)
|
||||||
}
|
}
|
||||||
AppType::GrokBuild => settings
|
// GrokBuild 的 base_url 与 api_key 必须各自解析:extract_credentials 在
|
||||||
.get("config")
|
// 凭据缺失时整个 Option 变 None,一并 unwrap_or_default 会把明明写在
|
||||||
.and_then(Value::as_str)
|
// 配置里的 base_url 也清成空串。凭据缺失是常态(env_key 指向的变量在
|
||||||
|
// GUI 进程里读不到),端点不该被连坐——否则用量脚本的 {{baseUrl}} 变成
|
||||||
|
// 相对路径、余额查询只报「API key is empty」掩盖真因。
|
||||||
|
// 与上面 Codex 分支的写法保持一致。
|
||||||
|
AppType::GrokBuild => {
|
||||||
|
let config_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
let base_url = config_text
|
||||||
|
.and_then(crate::grok_config::extract_base_url)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let api_key = config_text
|
||||||
.and_then(crate::grok_config::extract_credentials)
|
.and_then(crate::grok_config::extract_credentials)
|
||||||
.unwrap_or_default(),
|
.map(|(_, api_key)| api_key)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(base_url, api_key)
|
||||||
|
}
|
||||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||||
AppType::Hermes => (
|
AppType::Hermes => (
|
||||||
str_at(settings.get("base_url")),
|
str_at(settings.get("base_url")),
|
||||||
|
|||||||
@@ -589,6 +589,21 @@ pub(crate) fn responses_sse_events_from_anthropic_message(
|
|||||||
tool_context: CodexToolContext,
|
tool_context: CodexToolContext,
|
||||||
) -> Vec<Bytes> {
|
) -> Vec<Bytes> {
|
||||||
let mut state = AnthropicToResponsesState::with_tool_context(tool_context);
|
let mut state = AnthropicToResponsesState::with_tool_context(tool_context);
|
||||||
|
|
||||||
|
// The whole conversion below assumes `body` is an Anthropic message object.
|
||||||
|
// A misbehaving gateway can return a top-level JSON array (or scalar) with
|
||||||
|
// HTTP 200 despite `stream:true`; index-assigning `message_start["content"]`
|
||||||
|
// on such a non-object would panic. Bail out gracefully instead.
|
||||||
|
if !body.is_object() {
|
||||||
|
return state
|
||||||
|
.failed_event(
|
||||||
|
"upstream returned a non-object Anthropic message body".to_string(),
|
||||||
|
Some("invalid_response".to_string()),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
|
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
|
||||||
let (message, error_type) = extract_anthropic_sse_error(body);
|
let (message, error_type) = extract_anthropic_sse_error(body);
|
||||||
return state
|
return state
|
||||||
@@ -819,6 +834,15 @@ mod tests {
|
|||||||
assert!(!merged.contains("stream_truncated"));
|
assert!(!merged.contains("stream_truncated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_non_object_body_returns_failed_event_not_panic() {
|
||||||
|
// A gateway that ignores `stream:true` and returns a top-level JSON array
|
||||||
|
// would have panicked on `message_start["content"] = …` before the guard.
|
||||||
|
let merged = render_message_events(&json!([1, 2, 3]));
|
||||||
|
assert!(merged.contains("event: response.failed"));
|
||||||
|
assert!(merged.contains("invalid_response"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_json_message_becomes_complete_responses_stream() {
|
fn test_json_message_becomes_complete_responses_stream() {
|
||||||
let merged = render_message_events(&json!({
|
let merged = render_message_events(&json!({
|
||||||
|
|||||||
@@ -1418,13 +1418,39 @@ pub fn anthropic_sse_to_message_value(body: &str) -> Result<Value, ProxyError> {
|
|||||||
};
|
};
|
||||||
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||||
"message_start" => {
|
"message_start" => {
|
||||||
if let Some(msg) = value.get("message") {
|
// Only accept an object message; a malformed upstream could send a
|
||||||
|
// scalar/array here, and the later `message["content"] = …` index
|
||||||
|
// assignment would panic on a non-object Value.
|
||||||
|
if let Some(msg) = value.get("message").filter(|m| m.is_object()) {
|
||||||
*message = Some(msg.clone());
|
*message = Some(msg.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"content_block_start" => {
|
"content_block_start" => {
|
||||||
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
|
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
|
||||||
let block = value.get("content_block").cloned().unwrap_or(json!({}));
|
// Sanitize to an object: any later index-assignment (`["text"]`,
|
||||||
|
// `["signature"]`, `["input"]`) requires a JSON object, so a
|
||||||
|
// malformed non-object block from the upstream cannot be stored
|
||||||
|
// verbatim (it would panic on the next delta).
|
||||||
|
//
|
||||||
|
// The replacement carries `type: "text"` rather than being empty:
|
||||||
|
// the deltas that follow are usually well-formed, and a block with
|
||||||
|
// no `type` is silently dropped by the final Responses conversion,
|
||||||
|
// which turns a garbled block header into a `completed` response
|
||||||
|
// with empty output — the client sees the model saying nothing and
|
||||||
|
// has no way to tell that data was discarded. A text block recovers
|
||||||
|
// the common case; a tool-use block still yields nothing, exactly as
|
||||||
|
// it did before.
|
||||||
|
let block = match value.get("content_block") {
|
||||||
|
Some(block) if block.is_object() => block.clone(),
|
||||||
|
malformed => {
|
||||||
|
if malformed.is_some() {
|
||||||
|
log::warn!(
|
||||||
|
"Anthropic upstream sent a non-object content_block at index {index}; recovering it as a text block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
json!({ "type": "text" })
|
||||||
|
}
|
||||||
|
};
|
||||||
blocks.insert(index, block);
|
blocks.insert(index, block);
|
||||||
json_accum.entry(index).or_default();
|
json_accum.entry(index).or_default();
|
||||||
}
|
}
|
||||||
@@ -2953,4 +2979,42 @@ data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":
|
|||||||
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
|
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
|
||||||
assert!(anthropic_sse_to_message_value(sse).is_err());
|
assert!(anthropic_sse_to_message_value(sse).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_sse_aggregation_non_object_content_block_does_not_panic() {
|
||||||
|
// A malformed upstream can send a non-object `content_block`; the index
|
||||||
|
// assignment on the next delta would have panicked before the shape guard.
|
||||||
|
let sse = concat!(
|
||||||
|
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n",
|
||||||
|
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":[1]}\n\n",
|
||||||
|
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"x\"}}\n\n",
|
||||||
|
"data: {\"type\":\"message_stop\"}\n\n",
|
||||||
|
);
|
||||||
|
let msg = anthropic_sse_to_message_value(sse)
|
||||||
|
.expect("aggregation must not panic on a non-object content_block");
|
||||||
|
assert_eq!(msg["content"][0]["text"], json!("x"));
|
||||||
|
|
||||||
|
// Not panicking is only half of it: the sanitized block must still carry a
|
||||||
|
// `type`, because the final conversion matches on it and silently drops
|
||||||
|
// anything it does not recognise. Asserting only on the intermediate value
|
||||||
|
// would pass while the client receives a `completed` response with empty
|
||||||
|
// output and no indication that the text was thrown away.
|
||||||
|
let response = anthropic_response_to_responses(msg).expect("final conversion must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
response["output"][0]["content"][0]["text"],
|
||||||
|
json!("x"),
|
||||||
|
"text recovered from a malformed block must survive to the Responses output: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_sse_aggregation_non_object_message_errors_not_panic() {
|
||||||
|
// A malformed upstream can send a scalar `message`; the later
|
||||||
|
// `message["content"] = …` would have panicked before the shape guard.
|
||||||
|
let sse = concat!(
|
||||||
|
"data: {\"type\":\"message_start\",\"message\":\"oops\"}\n\n",
|
||||||
|
"data: {\"type\":\"message_stop\"}\n\n",
|
||||||
|
);
|
||||||
|
assert!(anthropic_sse_to_message_value(sse).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod env_checker;
|
|||||||
pub mod env_manager;
|
pub mod env_manager;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod model_fetch;
|
pub mod model_fetch;
|
||||||
|
pub mod model_pricing;
|
||||||
pub mod omo;
|
pub mod omo;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
|||||||
@@ -0,0 +1,761 @@
|
|||||||
|
use crate::config::{atomic_write, get_app_config_dir};
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use rusqlite::{params, Transaction};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
const MODEL_PRICING_FILE_NAME: &str = "model-pricing.json";
|
||||||
|
const MODEL_PRICING_FILE_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
static MODEL_PRICING_FILE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn file_lock() -> &'static Mutex<()> {
|
||||||
|
MODEL_PRICING_FILE_LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_file_version() -> u32 {
|
||||||
|
MODEL_PRICING_FILE_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelPricingInfo {
|
||||||
|
pub model_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub input_cost_per_million: String,
|
||||||
|
pub output_cost_per_million: String,
|
||||||
|
pub cache_read_cost_per_million: String,
|
||||||
|
pub cache_creation_cost_per_million: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelsDevSyncConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_sync_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub include_common_models: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub selected_model_keys: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub excluded_common_model_keys: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_sync_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_sync_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelsDevSyncConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
auto_sync_enabled: false,
|
||||||
|
include_common_models: true,
|
||||||
|
selected_model_keys: Vec::new(),
|
||||||
|
excluded_common_model_keys: Vec::new(),
|
||||||
|
last_sync_at: None,
|
||||||
|
last_sync_error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ModelPricingFile {
|
||||||
|
#[serde(default = "default_file_version")]
|
||||||
|
version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
models_dev_sync: ModelsDevSyncConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
models: Vec<ModelPricingInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
deleted_model_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelPricingFile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: MODEL_PRICING_FILE_VERSION,
|
||||||
|
models_dev_sync: ModelsDevSyncConfig::default(),
|
||||||
|
models: Vec::new(),
|
||||||
|
deleted_model_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelsDevSyncState {
|
||||||
|
pub config: ModelsDevSyncConfig,
|
||||||
|
pub config_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn model_pricing_file_path() -> PathBuf {
|
||||||
|
get_app_config_dir().join(MODEL_PRICING_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_decimal(label: &str, value: &str) -> Result<String, AppError> {
|
||||||
|
let value = value.trim();
|
||||||
|
let parsed = Decimal::from_str(value).map_err(|error| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage.invalidPrice",
|
||||||
|
format!("{label} 价格无效: {value} - {error}"),
|
||||||
|
format!("{label} price is invalid: {value} - {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if parsed < Decimal::ZERO {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage.invalidPrice",
|
||||||
|
format!("{label} 价格必须为非负数: {value}"),
|
||||||
|
format!("{label} price must be non-negative: {value}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_pricing(entry: ModelPricingInfo) -> Result<ModelPricingInfo, AppError> {
|
||||||
|
let model_id = entry.model_id.trim().to_string();
|
||||||
|
let display_name = entry.display_name.trim().to_string();
|
||||||
|
if model_id.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage.modelIdRequired",
|
||||||
|
"模型 ID 不能为空",
|
||||||
|
"Model ID is required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if display_name.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage.displayNameRequired",
|
||||||
|
"显示名称不能为空",
|
||||||
|
"Display name is required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ModelPricingInfo {
|
||||||
|
model_id,
|
||||||
|
display_name,
|
||||||
|
input_cost_per_million: normalize_decimal("input_cost", &entry.input_cost_per_million)?,
|
||||||
|
output_cost_per_million: normalize_decimal("output_cost", &entry.output_cost_per_million)?,
|
||||||
|
cache_read_cost_per_million: normalize_decimal(
|
||||||
|
"cache_read_cost",
|
||||||
|
&entry.cache_read_cost_per_million,
|
||||||
|
)?,
|
||||||
|
cache_creation_cost_per_million: normalize_decimal(
|
||||||
|
"cache_creation_cost",
|
||||||
|
&entry.cache_creation_cost_per_million,
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_key_list(values: Vec<String>) -> Vec<String> {
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_sync_config(mut config: ModelsDevSyncConfig) -> ModelsDevSyncConfig {
|
||||||
|
config.selected_model_keys = normalize_key_list(config.selected_model_keys);
|
||||||
|
config.excluded_common_model_keys = normalize_key_list(config.excluded_common_model_keys);
|
||||||
|
config.last_sync_error = config.last_sync_error.and_then(|error| {
|
||||||
|
let trimmed = error.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.chars().take(1000).collect())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_file(mut file: ModelPricingFile) -> Result<ModelPricingFile, AppError> {
|
||||||
|
if file.version > MODEL_PRICING_FILE_VERSION {
|
||||||
|
return Err(AppError::Config(format!(
|
||||||
|
"model-pricing.json version {} is newer than supported version {}",
|
||||||
|
file.version, MODEL_PRICING_FILE_VERSION
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = normalize_key_list(file.deleted_model_ids)
|
||||||
|
.into_iter()
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut models = BTreeMap::new();
|
||||||
|
for entry in file.models {
|
||||||
|
let entry = normalize_pricing(entry)?;
|
||||||
|
if !deleted.contains(&entry.model_id) {
|
||||||
|
models.insert(entry.model_id.clone(), entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.version = MODEL_PRICING_FILE_VERSION;
|
||||||
|
file.models_dev_sync = normalize_sync_config(file.models_dev_sync);
|
||||||
|
file.models = models.into_values().collect();
|
||||||
|
file.deleted_model_ids = deleted.into_iter().collect();
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file_unlocked() -> Result<Option<ModelPricingFile>, AppError> {
|
||||||
|
let path = model_pricing_file_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
|
||||||
|
let file = serde_json::from_str(&content).map_err(|error| AppError::json(&path, error))?;
|
||||||
|
normalize_file(file).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file_unlocked(file: &ModelPricingFile) -> Result<(), AppError> {
|
||||||
|
let path = model_pricing_file_path();
|
||||||
|
let mut data = serde_json::to_vec_pretty(file)
|
||||||
|
.map_err(|error| AppError::Config(format!("序列化模型定价配置失败: {error}")))?;
|
||||||
|
data.push(b'\n');
|
||||||
|
atomic_write(&path, &data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_or_create_file_unlocked() -> Result<ModelPricingFile, AppError> {
|
||||||
|
if let Some(file) = read_file_unlocked()? {
|
||||||
|
return Ok(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The local file stores user/models.dev overrides only. Exporting the
|
||||||
|
// complete seeded table here would turn built-in prices into overrides and
|
||||||
|
// roll back future repair_current_model_pricing corrections on startup.
|
||||||
|
let file = ModelPricingFile::default();
|
||||||
|
write_file_unlocked(&file)?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_pricing(
|
||||||
|
transaction: &Transaction<'_>,
|
||||||
|
entry: &ModelPricingInfo,
|
||||||
|
) -> Result<usize, AppError> {
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT 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)
|
||||||
|
ON CONFLICT(model_id) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
input_cost_per_million = excluded.input_cost_per_million,
|
||||||
|
output_cost_per_million = excluded.output_cost_per_million,
|
||||||
|
cache_read_cost_per_million = excluded.cache_read_cost_per_million,
|
||||||
|
cache_creation_cost_per_million = excluded.cache_creation_cost_per_million
|
||||||
|
WHERE display_name <> excluded.display_name
|
||||||
|
OR input_cost_per_million <> excluded.input_cost_per_million
|
||||||
|
OR output_cost_per_million <> excluded.output_cost_per_million
|
||||||
|
OR cache_read_cost_per_million <> excluded.cache_read_cost_per_million
|
||||||
|
OR cache_creation_cost_per_million <> excluded.cache_creation_cost_per_million",
|
||||||
|
params![
|
||||||
|
entry.model_id,
|
||||||
|
entry.display_name,
|
||||||
|
entry.input_cost_per_million,
|
||||||
|
entry.output_cost_per_million,
|
||||||
|
entry.cache_read_cost_per_million,
|
||||||
|
entry.cache_creation_cost_per_million
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(format!("更新模型定价失败: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_file_to_database(
|
||||||
|
db: &Database,
|
||||||
|
file: &ModelPricingFile,
|
||||||
|
) -> Result<(usize, usize), AppError> {
|
||||||
|
let mut conn = lock_conn!(db.conn);
|
||||||
|
let transaction = conn.transaction()?;
|
||||||
|
let mut upserted = 0;
|
||||||
|
for entry in &file.models {
|
||||||
|
upserted += upsert_pricing(&transaction, entry)?;
|
||||||
|
}
|
||||||
|
let mut deleted = 0;
|
||||||
|
for model_id in &file.deleted_model_ids {
|
||||||
|
deleted += transaction.execute(
|
||||||
|
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||||
|
params![model_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok((upserted, deleted))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load user-maintained overrides from `~/.cc-switch/model-pricing.json`.
|
||||||
|
/// Built-in rows remain database-owned so application updates can repair them;
|
||||||
|
/// the file contains only explicit overrides and deletion tombstones.
|
||||||
|
pub fn sync_local_model_pricing(db: &Database) -> Result<usize, AppError> {
|
||||||
|
let (upserted, deleted) = {
|
||||||
|
let _file_guard = file_lock()
|
||||||
|
.lock()
|
||||||
|
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||||
|
let file = load_or_create_file_unlocked()?;
|
||||||
|
apply_file_to_database(db, &file)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deleting pricing cannot make a zero-cost usage row calculable. In
|
||||||
|
// particular, seeded rows covered by tombstones may be reinserted and
|
||||||
|
// deleted on every startup; they must not trigger a full-table backfill.
|
||||||
|
if upserted > 0 {
|
||||||
|
if let Err(error) = db.backfill_missing_usage_costs() {
|
||||||
|
log::warn!("本地模型定价同步后回填历史用量成本失败: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(upserted + deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_models_dev_sync_state(db: &Database) -> Result<ModelsDevSyncState, AppError> {
|
||||||
|
sync_local_model_pricing(db)?;
|
||||||
|
let _file_guard = file_lock()
|
||||||
|
.lock()
|
||||||
|
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||||
|
let file = load_or_create_file_unlocked()?;
|
||||||
|
Ok(ModelsDevSyncState {
|
||||||
|
config: file.models_dev_sync,
|
||||||
|
config_path: model_pricing_file_path().display().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_models_dev_sync_config(
|
||||||
|
db: &Database,
|
||||||
|
config: ModelsDevSyncConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sync_local_model_pricing(db)?;
|
||||||
|
let _file_guard = file_lock()
|
||||||
|
.lock()
|
||||||
|
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||||
|
let mut file = load_or_create_file_unlocked()?;
|
||||||
|
file.models_dev_sync = normalize_sync_config(config);
|
||||||
|
write_file_unlocked(&file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist only the outcome of a models.dev sync. Keeping this separate from
|
||||||
|
/// `save_models_dev_sync_config` prevents a slow startup fetch from restoring
|
||||||
|
/// stale switches or model selections that the user changed in the meantime.
|
||||||
|
pub fn record_models_dev_sync_result(
|
||||||
|
db: &Database,
|
||||||
|
synced_at: Option<i64>,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sync_local_model_pricing(db)?;
|
||||||
|
let _file_guard = file_lock()
|
||||||
|
.lock()
|
||||||
|
.map_err(|lock_error| AppError::Config(format!("模型定价文件锁失败: {lock_error}")))?;
|
||||||
|
let mut file = load_or_create_file_unlocked()?;
|
||||||
|
if let Some(synced_at) = synced_at {
|
||||||
|
file.models_dev_sync.last_sync_at = Some(synced_at);
|
||||||
|
}
|
||||||
|
file.models_dev_sync.last_sync_error = error;
|
||||||
|
file.models_dev_sync = normalize_sync_config(file.models_dev_sync);
|
||||||
|
write_file_unlocked(&file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_model_pricing_batch_inner(
|
||||||
|
db: &Database,
|
||||||
|
entries: Vec<ModelPricingInfo>,
|
||||||
|
backfill_all: bool,
|
||||||
|
) -> Result<usize, AppError> {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut normalized = BTreeMap::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = normalize_pricing(entry)?;
|
||||||
|
normalized.insert(entry.model_id.clone(), entry);
|
||||||
|
}
|
||||||
|
let entries = normalized.into_values().collect::<Vec<_>>();
|
||||||
|
let model_ids = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.model_id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
sync_local_model_pricing(db)?;
|
||||||
|
let changed = {
|
||||||
|
let _file_guard = file_lock()
|
||||||
|
.lock()
|
||||||
|
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||||
|
let mut file = load_or_create_file_unlocked()?;
|
||||||
|
let mut file_models = file
|
||||||
|
.models
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| (entry.model_id.clone(), entry))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let updated_ids = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.model_id.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
for entry in &entries {
|
||||||
|
file_models.insert(entry.model_id.clone(), entry.clone());
|
||||||
|
}
|
||||||
|
file.models = file_models.into_values().collect();
|
||||||
|
file.deleted_model_ids
|
||||||
|
.retain(|model_id| !updated_ids.contains(model_id));
|
||||||
|
|
||||||
|
let mut conn = lock_conn!(db.conn);
|
||||||
|
let transaction = conn.transaction()?;
|
||||||
|
let mut changed = 0;
|
||||||
|
for entry in &entries {
|
||||||
|
changed += upsert_pricing(&transaction, entry)?;
|
||||||
|
}
|
||||||
|
write_file_unlocked(&file)?;
|
||||||
|
transaction.commit()?;
|
||||||
|
changed
|
||||||
|
};
|
||||||
|
|
||||||
|
if changed > 0 {
|
||||||
|
if backfill_all {
|
||||||
|
if let Err(error) = db.backfill_missing_usage_costs() {
|
||||||
|
log::warn!("批量更新模型定价后回填历史用量成本失败: {error}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for model_id in model_ids {
|
||||||
|
if let Err(error) = db.backfill_missing_usage_costs_for_model(&model_id) {
|
||||||
|
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_model_pricing(db: &Database, entry: ModelPricingInfo) -> Result<usize, AppError> {
|
||||||
|
update_model_pricing_batch_inner(db, vec![entry], false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_model_pricing_batch(
|
||||||
|
db: &Database,
|
||||||
|
entries: Vec<ModelPricingInfo>,
|
||||||
|
) -> Result<usize, AppError> {
|
||||||
|
update_model_pricing_batch_inner(db, entries, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_model_pricing(db: &Database, model_id: &str) -> Result<(), AppError> {
|
||||||
|
let model_id = model_id.trim();
|
||||||
|
if model_id.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage.modelIdRequired",
|
||||||
|
"模型 ID 不能为空",
|
||||||
|
"Model ID is required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_local_model_pricing(db)?;
|
||||||
|
let _file_guard = file_lock()
|
||||||
|
.lock()
|
||||||
|
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||||
|
let mut file = load_or_create_file_unlocked()?;
|
||||||
|
file.models.retain(|entry| entry.model_id != model_id);
|
||||||
|
if !file.deleted_model_ids.iter().any(|entry| entry == model_id) {
|
||||||
|
file.deleted_model_ids.push(model_id.to_string());
|
||||||
|
file.deleted_model_ids.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = lock_conn!(db.conn);
|
||||||
|
let transaction = conn.transaction()?;
|
||||||
|
transaction.execute(
|
||||||
|
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||||
|
params![model_id],
|
||||||
|
)?;
|
||||||
|
write_file_unlocked(&file)?;
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
fn with_test_home(test: impl FnOnce(&Database, &PathBuf)) {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let previous = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||||
|
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||||
|
|
||||||
|
let db = Database::memory().expect("memory database");
|
||||||
|
let path = model_pricing_file_path();
|
||||||
|
test(&db, &path);
|
||||||
|
|
||||||
|
match previous {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_pricing() -> ModelPricingInfo {
|
||||||
|
ModelPricingInfo {
|
||||||
|
model_id: "custom-model".to_string(),
|
||||||
|
display_name: "Custom Model".to_string(),
|
||||||
|
input_cost_per_million: "1.25".to_string(),
|
||||||
|
output_cost_per_million: "5".to_string(),
|
||||||
|
cache_read_cost_per_million: "0.1".to_string(),
|
||||||
|
cache_creation_cost_per_million: "1.5".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn creates_local_file_with_auto_sync_disabled_by_default() {
|
||||||
|
with_test_home(|db, path| {
|
||||||
|
let state = get_models_dev_sync_state(db).expect("sync state");
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(!state.config.auto_sync_enabled);
|
||||||
|
assert!(state.config.include_common_models);
|
||||||
|
assert_eq!(state.config_path, path.display().to_string());
|
||||||
|
|
||||||
|
let content = fs::read_to_string(path).expect("read pricing file");
|
||||||
|
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||||
|
assert!(file.models.is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn empty_override_file_does_not_roll_back_builtin_pricing_repairs() {
|
||||||
|
with_test_home(|db, path| {
|
||||||
|
get_models_dev_sync_state(db).expect("create override file");
|
||||||
|
{
|
||||||
|
let conn = db.conn.lock().expect("lock test database");
|
||||||
|
assert_eq!(
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE model_pricing
|
||||||
|
SET input_cost_per_million = '99'
|
||||||
|
WHERE model_id = 'claude-sonnet-5'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("simulate built-in pricing repair"),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(sync_local_model_pricing(db).expect("reload overrides"), 0);
|
||||||
|
|
||||||
|
let conn = db.conn.lock().expect("lock test database");
|
||||||
|
let input: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT input_cost_per_million
|
||||||
|
FROM model_pricing WHERE model_id = 'claude-sonnet-5'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("query repaired pricing");
|
||||||
|
drop(conn);
|
||||||
|
assert_eq!(input, "99");
|
||||||
|
|
||||||
|
let content = fs::read_to_string(path).expect("read override file");
|
||||||
|
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||||
|
assert!(file.models.is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn models_dev_batch_sync_overwrites_existing_manual_pricing() {
|
||||||
|
with_test_home(|db, path| {
|
||||||
|
let mut manual = sample_pricing();
|
||||||
|
manual.input_cost_per_million = "9".to_string();
|
||||||
|
manual.output_cost_per_million = "18".to_string();
|
||||||
|
update_model_pricing(db, manual).expect("save manual pricing");
|
||||||
|
|
||||||
|
let synced = sample_pricing();
|
||||||
|
update_model_pricing_batch(db, vec![synced.clone()]).expect("sync models.dev pricing");
|
||||||
|
|
||||||
|
let conn = db.conn.lock().expect("lock test database");
|
||||||
|
let input: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT input_cost_per_million FROM model_pricing WHERE model_id = ?1",
|
||||||
|
params!["custom-model"],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("query synced pricing");
|
||||||
|
drop(conn);
|
||||||
|
assert_eq!(input, synced.input_cost_per_million);
|
||||||
|
|
||||||
|
let content = fs::read_to_string(path).expect("read pricing file");
|
||||||
|
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||||
|
let saved = file
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.model_id == "custom-model")
|
||||||
|
.expect("saved synced pricing");
|
||||||
|
assert_eq!(saved, &synced);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn batch_update_and_delete_are_persisted_to_local_file() {
|
||||||
|
with_test_home(|db, path| {
|
||||||
|
assert_eq!(
|
||||||
|
update_model_pricing_batch(db, vec![sample_pricing()]).expect("batch update"),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let content = fs::read_to_string(path).expect("read pricing file");
|
||||||
|
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||||
|
assert!(file
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.model_id == "custom-model"));
|
||||||
|
|
||||||
|
delete_model_pricing(db, "custom-model").expect("delete pricing");
|
||||||
|
let content = fs::read_to_string(path).expect("read updated file");
|
||||||
|
let file: ModelPricingFile =
|
||||||
|
serde_json::from_str(&content).expect("parse updated file");
|
||||||
|
assert!(!file
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.model_id == "custom-model"));
|
||||||
|
assert!(file
|
||||||
|
.deleted_model_ids
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry == "custom-model"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn reloads_manual_file_edits_and_deletion_tombstones() {
|
||||||
|
with_test_home(|db, path| {
|
||||||
|
get_models_dev_sync_state(db).expect("create pricing file");
|
||||||
|
let content = fs::read_to_string(path).expect("read pricing file");
|
||||||
|
let mut file: ModelPricingFile =
|
||||||
|
serde_json::from_str(&content).expect("parse pricing file");
|
||||||
|
file.models.push(sample_pricing());
|
||||||
|
fs::write(
|
||||||
|
path,
|
||||||
|
serde_json::to_vec_pretty(&file).expect("serialize file"),
|
||||||
|
)
|
||||||
|
.expect("write manual edit");
|
||||||
|
|
||||||
|
assert_eq!(sync_local_model_pricing(db).expect("reload file"), 1);
|
||||||
|
{
|
||||||
|
let conn = db.conn.lock().expect("lock test database");
|
||||||
|
let input: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT input_cost_per_million FROM model_pricing WHERE model_id = ?1",
|
||||||
|
params!["custom-model"],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("query manually added pricing");
|
||||||
|
assert_eq!(input, "1.25");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(path).expect("read updated pricing file");
|
||||||
|
let mut file: ModelPricingFile =
|
||||||
|
serde_json::from_str(&content).expect("parse updated pricing file");
|
||||||
|
file.deleted_model_ids.push("custom-model".to_string());
|
||||||
|
fs::write(
|
||||||
|
path,
|
||||||
|
serde_json::to_vec_pretty(&file).expect("serialize tombstone"),
|
||||||
|
)
|
||||||
|
.expect("write tombstone");
|
||||||
|
|
||||||
|
assert_eq!(sync_local_model_pricing(db).expect("apply tombstone"), 1);
|
||||||
|
let conn = db.conn.lock().expect("lock test database");
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing WHERE model_id = ?1",
|
||||||
|
params!["custom-model"],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("query deleted pricing");
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn repeated_seeded_tombstone_deletion_does_not_backfill_unrelated_usage() {
|
||||||
|
with_test_home(|db, _path| {
|
||||||
|
get_models_dev_sync_state(db).expect("create override file");
|
||||||
|
{
|
||||||
|
let conn = db.conn.lock().expect("lock test database");
|
||||||
|
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,
|
||||||
|
status_code, created_at, data_source
|
||||||
|
) VALUES (
|
||||||
|
'pending-cost', 'test-provider', 'codex', 'gpt-5', 'gpt-5',
|
||||||
|
1000000, 0, 0, 0, '0', '0', '0', '0', '0', 100, 200, 1, 'proxy'
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("insert zero-cost usage");
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_model_pricing(db, "claude-sonnet-5").expect("create tombstone");
|
||||||
|
db.ensure_model_pricing_seeded()
|
||||||
|
.expect("reseed built-in pricing");
|
||||||
|
assert_eq!(sync_local_model_pricing(db).expect("apply tombstone"), 1);
|
||||||
|
|
||||||
|
let conn = db.conn.lock().expect("lock test database");
|
||||||
|
let deleted_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing
|
||||||
|
WHERE model_id = 'claude-sonnet-5'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("query tombstoned pricing");
|
||||||
|
let total_cost: f64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT CAST(total_cost_usd AS REAL)
|
||||||
|
FROM proxy_request_logs WHERE request_id = 'pending-cost'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("query pending usage cost");
|
||||||
|
assert_eq!(deleted_count, 0);
|
||||||
|
assert_eq!(total_cost, 0.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn recording_sync_result_preserves_user_selection_and_switches() {
|
||||||
|
with_test_home(|db, _path| {
|
||||||
|
let config = ModelsDevSyncConfig {
|
||||||
|
auto_sync_enabled: false,
|
||||||
|
include_common_models: false,
|
||||||
|
selected_model_keys: vec!["relay/custom-model".to_string()],
|
||||||
|
excluded_common_model_keys: vec!["openai/gpt-5".to_string()],
|
||||||
|
last_sync_at: Some(123),
|
||||||
|
last_sync_error: Some("old error".to_string()),
|
||||||
|
};
|
||||||
|
save_models_dev_sync_config(db, config.clone()).expect("save sync config");
|
||||||
|
|
||||||
|
record_models_dev_sync_result(db, Some(456), None).expect("record success");
|
||||||
|
let state = get_models_dev_sync_state(db).expect("read sync state");
|
||||||
|
assert_eq!(state.config.auto_sync_enabled, config.auto_sync_enabled);
|
||||||
|
assert_eq!(
|
||||||
|
state.config.include_common_models,
|
||||||
|
config.include_common_models
|
||||||
|
);
|
||||||
|
assert_eq!(state.config.selected_model_keys, config.selected_model_keys);
|
||||||
|
assert_eq!(
|
||||||
|
state.config.excluded_common_model_keys,
|
||||||
|
config.excluded_common_model_keys
|
||||||
|
);
|
||||||
|
assert_eq!(state.config.last_sync_at, Some(456));
|
||||||
|
assert_eq!(state.config.last_sync_error, None);
|
||||||
|
|
||||||
|
record_models_dev_sync_result(db, None, Some("offline".to_string()))
|
||||||
|
.expect("record failure");
|
||||||
|
let state = get_models_dev_sync_state(db).expect("read failure state");
|
||||||
|
assert_eq!(state.config.last_sync_at, Some(456));
|
||||||
|
assert_eq!(state.config.last_sync_error.as_deref(), Some("offline"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -702,6 +702,469 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_gemini_common_config_strips_credentials_keeps_shareable() {
|
||||||
|
// Gemini 的共享片段会被 deep-merge 回**其它** Gemini 供应商的 env
|
||||||
|
// (live.rs::apply_common_config_to_settings),因此任何凭据都不得进入片段。
|
||||||
|
// 之前这里只硬编码跳过 GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL,而
|
||||||
|
// GOOGLE_API_KEY 是 provider.rs 认可的一等 Gemini 凭据 → 会泄露到别的供应商。
|
||||||
|
let settings = json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "g-gem",
|
||||||
|
"GOOGLE_API_KEY": "g-legacy-real-key",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://gemini.example",
|
||||||
|
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
|
||||||
|
"SOME_PROXY_AUTH_TOKEN": "tok-proxy",
|
||||||
|
// 可共享的非机密配置必须保留
|
||||||
|
"GEMINI_TIMEOUT_MS": "30000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let snippet =
|
||||||
|
ProviderService::extract_gemini_common_config(&settings).expect("extract should work");
|
||||||
|
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||||
|
|
||||||
|
for leaked in [
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||||
|
"SOME_PROXY_AUTH_TOKEN",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
value.get(leaked).is_none(),
|
||||||
|
"credential {leaked} must not leak into the shared Gemini snippet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
value.get("GEMINI_TIMEOUT_MS").and_then(|v| v.as_str()),
|
||||||
|
Some("30000"),
|
||||||
|
"shareable non-secret config must be preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 造一个「已被污染」的现场:片段里带 A 账号的凭据 + 一个合法可共享键。
|
||||||
|
#[test]
|
||||||
|
fn sensitive_key_matcher_covers_common_credential_namings() {
|
||||||
|
for key in [
|
||||||
|
// 裸 `_KEY`:最常见的写法,却曾被"只枚举 `_API_KEY` 这些子类"漏在外面
|
||||||
|
"OPENAI_KEY",
|
||||||
|
"GROQ_KEY",
|
||||||
|
"XAI_KEY",
|
||||||
|
// 不带分隔符的复合写法
|
||||||
|
"VOLC_ACCESSKEY",
|
||||||
|
"ALIYUN_SECRETKEY",
|
||||||
|
"SOME_APITOKEN",
|
||||||
|
// personal access token:既不含 TOKEN 也不含 KEY
|
||||||
|
"GITHUB_PAT",
|
||||||
|
"gitlab_pat",
|
||||||
|
// 口令类缩写
|
||||||
|
"MYSQL_PWD",
|
||||||
|
"DB_PASS",
|
||||||
|
"GPG_PASSPHRASE",
|
||||||
|
"AWS_CREDS",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
ProviderService::is_sensitive_config_key(key),
|
||||||
|
"{key} must be treated as a credential"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后缀必须带下划线,不能把正常配置一起卷进来
|
||||||
|
for key in [
|
||||||
|
"PATH",
|
||||||
|
"OLDPWD",
|
||||||
|
"GEMINI_COMPAT",
|
||||||
|
"SSL_BYPASS",
|
||||||
|
"GEMINI_TIMEOUT_MS",
|
||||||
|
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!ProviderService::is_sensitive_config_key(key),
|
||||||
|
"{key} is ordinary shareable config and must not be stripped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_leaked_gemini_state(db: &Arc<Database>) {
|
||||||
|
db.set_config_snippet(
|
||||||
|
"gemini",
|
||||||
|
Some(
|
||||||
|
json!({
|
||||||
|
"GOOGLE_API_KEY": "key-A-leaked",
|
||||||
|
"SOME_PROXY_AUTH_TOKEN": "tok-A-leaked",
|
||||||
|
"GEMINI_TIMEOUT_MS": "30000"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("seed snippet");
|
||||||
|
|
||||||
|
// 受害者 B:泄漏的密钥已经被合并进它的 env
|
||||||
|
let victim = Provider::with_id(
|
||||||
|
"b".into(),
|
||||||
|
"Relay B".into(),
|
||||||
|
json!({ "env": {
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://relay-b.example",
|
||||||
|
"GOOGLE_API_KEY": "key-A-leaked",
|
||||||
|
"GEMINI_TIMEOUT_MS": "30000"
|
||||||
|
}}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("gemini", &victim).expect("save victim");
|
||||||
|
|
||||||
|
// 供应商 C:自己写了同名键但值不同,不能被误删
|
||||||
|
let unrelated = Provider::with_id(
|
||||||
|
"c".into(),
|
||||||
|
"Own Key C".into(),
|
||||||
|
json!({ "env": {
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://c.example",
|
||||||
|
"GOOGLE_API_KEY": "key-C-owned"
|
||||||
|
}}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("gemini", &unrelated).expect("save c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_removes_leaked_credentials_from_snippet_and_providers() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
// 片段:凭据清掉,可共享配置保留
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.expect("snippet must still exist");
|
||||||
|
let snippet: Value = serde_json::from_str(&snippet).expect("valid json");
|
||||||
|
assert!(snippet.get("GOOGLE_API_KEY").is_none());
|
||||||
|
assert!(snippet.get("SOME_PROXY_AUTH_TOKEN").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
snippet.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
|
||||||
|
Some("30000"),
|
||||||
|
"shareable config must survive the scrub"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 受害者 B:扩散过去的那一份被清掉
|
||||||
|
let providers = db.get_all_providers("gemini").expect("providers");
|
||||||
|
let victim_env = &providers["b"].settings_config["env"];
|
||||||
|
assert!(
|
||||||
|
victim_env.get("GOOGLE_API_KEY").is_none(),
|
||||||
|
"leaked key must be removed from the victim provider"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
victim_env.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
|
||||||
|
Some("30000"),
|
||||||
|
"non-credential config must not be touched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_keeps_a_providers_own_differently_valued_key() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
// 这条最容易写错成「按键名一刀切」:C 自己的密钥值与片段不同,是它自己的凭据
|
||||||
|
let providers = db.get_all_providers("gemini").expect("providers");
|
||||||
|
assert_eq!(
|
||||||
|
providers["c"].settings_config["env"]
|
||||||
|
.get("GOOGLE_API_KEY")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("key-C-owned"),
|
||||||
|
"a provider's own key must not be deleted by name matching"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_audit_records_key_names_but_never_values() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
let audit_text = db
|
||||||
|
.get_setting("gemini_common_config_scrub_audit_v1")
|
||||||
|
.expect("read audit")
|
||||||
|
.expect("an audit record must exist so the deletion is not silent");
|
||||||
|
|
||||||
|
// 值绝不能进这条记录:`settings` 会随 WebDAV/S3 同步上传,留值等于把一次
|
||||||
|
// 清除换成一份跨设备扩散、没有界面入口、永不过期的明文副本。
|
||||||
|
assert!(
|
||||||
|
!audit_text.contains("key-A-leaked") && !audit_text.contains("tok-A-leaked"),
|
||||||
|
"the audit record must never carry credential values: {audit_text}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 但必须说清楚删了什么、从哪删的,否则用户只能靠翻日志
|
||||||
|
let audit: Value = serde_json::from_str(&audit_text).expect("audit is JSON");
|
||||||
|
let removed: Vec<&str> = audit["removedFromSnippet"]
|
||||||
|
.as_array()
|
||||||
|
.expect("removedFromSnippet array")
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
removed.contains(&"GOOGLE_API_KEY") && removed.contains(&"SOME_PROXY_AUTH_TOKEN"),
|
||||||
|
"every key removed from the snippet must be named: {audit}"
|
||||||
|
);
|
||||||
|
let victim = audit["providers"]
|
||||||
|
.as_array()
|
||||||
|
.expect("providers array")
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["id"] == json!("b"))
|
||||||
|
.expect("every provider whose config gets rewritten must be recorded");
|
||||||
|
assert_eq!(
|
||||||
|
victim["removedKeys"],
|
||||||
|
json!(["GOOGLE_API_KEY"]),
|
||||||
|
"the record must name what was taken from each provider: {audit}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_never_overwrites_an_existing_audit_record() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 上一轮改到一半就中止的情形:完成标记没置位,下次启动会重跑,但那时
|
||||||
|
// 读到的"原始状态"已经残缺。无条件覆盖会拿残缺记录盖掉第一轮那份完整的。
|
||||||
|
db.set_setting(
|
||||||
|
"gemini_common_config_scrub_audit_v1",
|
||||||
|
"{\"from\":\"an earlier, complete run\"}",
|
||||||
|
)
|
||||||
|
.expect("seed an existing audit record");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
db.get_setting("gemini_common_config_scrub_audit_v1")
|
||||||
|
.expect("read audit")
|
||||||
|
.as_deref(),
|
||||||
|
Some("{\"from\":\"an earlier, complete run\"}"),
|
||||||
|
"an audit record from an earlier run must survive a retry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_cleans_the_live_env_without_a_current_provider() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 没有当前供应商——这正是 sync_current_provider_for_app 直接返回 Ok 而
|
||||||
|
// 根本不写文件的分支。此时 live 若清不掉,片段又已被清空,下次切换的
|
||||||
|
// backfill 就会把残留永久写进受害供应商的配置。
|
||||||
|
crate::gemini_config::write_gemini_env_atomic(&HashMap::from([
|
||||||
|
("GOOGLE_API_KEY".to_string(), "key-A-leaked".to_string()),
|
||||||
|
("GEMINI_TIMEOUT_MS".to_string(), "30000".to_string()),
|
||||||
|
// 只存在于 live 的手工修改:定向删除必须保住它,全量重投影会抹掉
|
||||||
|
(
|
||||||
|
"HTTPS_PROXY".to_string(),
|
||||||
|
"http://127.0.0.1:7890".to_string(),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.expect("seed live env");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
let live = crate::gemini_config::read_gemini_env().expect("read live env");
|
||||||
|
assert!(
|
||||||
|
!live.contains_key("GOOGLE_API_KEY"),
|
||||||
|
"the leaked credential must be gone from ~/.gemini/.env: {live:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
live.get("HTTPS_PROXY").map(String::as_str),
|
||||||
|
Some("http://127.0.0.1:7890"),
|
||||||
|
"a hand-added live-only var must survive targeted removal: {live:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_live_cleanup_preserves_the_rest_of_the_env_file() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 这是一次用户没主动触发的启动期清理,不该顺手重写与泄漏无关的内容。
|
||||||
|
// read→HashMap→write 的往返会把注释、空行、无法识别的行全丢掉并按键名重排。
|
||||||
|
let original = "\
|
||||||
|
# my own notes
|
||||||
|
GOOGLE_API_KEY=key-C-owned
|
||||||
|
|
||||||
|
GOOGLE_API_KEY=key-A-leaked
|
||||||
|
this line is not KEY=VALUE at all
|
||||||
|
GEMINI_TIMEOUT_MS=30000
|
||||||
|
";
|
||||||
|
crate::gemini_config::write_gemini_env_text_atomic(original).expect("seed live env");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(crate::gemini_config::get_gemini_env_path())
|
||||||
|
.expect("read live env");
|
||||||
|
assert!(
|
||||||
|
!raw.contains("key-A-leaked"),
|
||||||
|
"the leaked line must be gone: {raw:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
raw.contains("# my own notes"),
|
||||||
|
"comments must survive a targeted removal: {raw:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
raw.contains("this line is not KEY=VALUE at all"),
|
||||||
|
"unparseable lines must survive a targeted removal: {raw:?}"
|
||||||
|
);
|
||||||
|
// 被泄漏值遮住的那条重新生效——正是想要的结果,遮住它的恰恰是泄漏值
|
||||||
|
assert_eq!(
|
||||||
|
crate::gemini_config::read_gemini_env()
|
||||||
|
.expect("read live env")
|
||||||
|
.get("GOOGLE_API_KEY")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("key-C-owned"),
|
||||||
|
"only the matching line may be dropped: {raw:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_aborts_before_clearing_the_snippet_when_the_live_backup_fails() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 关代理时这份快照会被原样写回 live。若清不动它却照样清了片段、置了完成标记,
|
||||||
|
// 代理一停凭据就复活,而一次性标记保证不会再清第二次。
|
||||||
|
db.save_live_backup("gemini", "}not json{")
|
||||||
|
.await
|
||||||
|
.expect("seed backup");
|
||||||
|
|
||||||
|
let result = ProviderService::scrub_leaked_gemini_common_config(&state).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"a backup that cannot be cleaned must abort the scrub"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 片段是「该剥哪些键」的唯一知识来源,中止后必须原样留着,否则下次重试
|
||||||
|
// 会因为 poison 为空而直接短路,反倒把标记置上
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.expect("snippet must still exist");
|
||||||
|
assert!(
|
||||||
|
snippet.contains("key-A-leaked"),
|
||||||
|
"the snippet must be left intact so the next boot can retry: {snippet}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.get_setting("gemini_common_config_credentials_scrubbed_v1")
|
||||||
|
.expect("read flag")
|
||||||
|
.is_none(),
|
||||||
|
"the one-shot flag must not be set when the scrub aborted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_leaves_no_residue_for_backfill_to_persist() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
// 顺序陷阱回归:如果只清了片段,切走供应商时 remove_common_config_from_settings
|
||||||
|
// 就不再认识这个键,live 里的残留会被 backfill 永久写进供应商配置。
|
||||||
|
// 清理必须是原子的——清完之后,任何地方都不该再有那个值。
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert!(!snippet.contains("key-A-leaked"));
|
||||||
|
|
||||||
|
for (id, provider) in db.get_all_providers("gemini").expect("providers") {
|
||||||
|
assert!(
|
||||||
|
!provider
|
||||||
|
.settings_config
|
||||||
|
.to_string()
|
||||||
|
.contains("key-A-leaked"),
|
||||||
|
"provider '{id}' still carries the leaked value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_is_idempotent_and_skips_on_second_run() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("first run");
|
||||||
|
|
||||||
|
// 第二次必须是 no-op:用户清理后重新填的凭据不能被再抹一遍
|
||||||
|
db.set_config_snippet(
|
||||||
|
"gemini",
|
||||||
|
Some(json!({"GOOGLE_API_KEY": "restored"}).to_string()),
|
||||||
|
)
|
||||||
|
.expect("user re-adds a value");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("second run");
|
||||||
|
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.expect("snippet exists");
|
||||||
|
assert!(
|
||||||
|
snippet.contains("restored"),
|
||||||
|
"the one-shot flag must prevent a second scrub: {snippet}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
|
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
|
||||||
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)
|
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)
|
||||||
@@ -3029,20 +3492,39 @@ impl ProviderService {
|
|||||||
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
||||||
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
||||||
/// 私钥命名。
|
/// 私钥命名。
|
||||||
fn is_sensitive_config_key(name: &str) -> bool {
|
pub(crate) fn is_sensitive_config_key(name: &str) -> bool {
|
||||||
let upper = name.to_ascii_uppercase();
|
let upper = name.to_ascii_uppercase();
|
||||||
|
|
||||||
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
||||||
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
||||||
const SENSITIVE_SUFFIXES: &[&str] = &[
|
const SENSITIVE_SUFFIXES: &[&str] = &[
|
||||||
|
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),
|
||||||
|
// 必须单列:只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通
|
||||||
|
// 的那一种漏在外面。下面几条 `_*_KEY` 被它蕴含,保留是为了说明覆盖面。
|
||||||
|
"_KEY",
|
||||||
"_API_KEY",
|
"_API_KEY",
|
||||||
"_APIKEY",
|
|
||||||
"_AUTH_TOKEN",
|
|
||||||
"_TOKEN",
|
|
||||||
"_ACCESS_KEY",
|
"_ACCESS_KEY",
|
||||||
"_ACCESS_KEY_ID",
|
"_ACCESS_KEY_ID",
|
||||||
"_KEY_ID",
|
"_KEY_ID",
|
||||||
"_PRIVATE_KEY",
|
"_PRIVATE_KEY",
|
||||||
|
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`
|
||||||
|
// (倒数第四个字符是 I 不是下划线)。VOLC_ACCESSKEY 是火山引擎文档
|
||||||
|
// 里的正式变量名,本仓库就实现了火山 AK/SK 用量查询。
|
||||||
|
"_APIKEY",
|
||||||
|
"_ACCESSKEY",
|
||||||
|
"_SECRETKEY",
|
||||||
|
"_APITOKEN",
|
||||||
|
"_AUTH_TOKEN",
|
||||||
|
"_TOKEN",
|
||||||
|
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
|
||||||
|
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
|
||||||
|
"_PAT",
|
||||||
|
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`(那个以 `_BYPASS`
|
||||||
|
// 结尾),`_PWD` 也不会误伤 shell 的 PWD / OLDPWD。
|
||||||
|
"_PWD",
|
||||||
|
"_PASS",
|
||||||
|
"_PASSPHRASE",
|
||||||
|
"_CREDS",
|
||||||
];
|
];
|
||||||
const SENSITIVE_EXACT: &[&str] = &[
|
const SENSITIVE_EXACT: &[&str] = &[
|
||||||
"APIKEY",
|
"APIKEY",
|
||||||
@@ -3242,7 +3724,13 @@ impl ProviderService {
|
|||||||
let mut snippet = serde_json::Map::new();
|
let mut snippet = serde_json::Map::new();
|
||||||
if let Some(env) = env {
|
if let Some(env) = env {
|
||||||
for (key, value) in env {
|
for (key, value) in env {
|
||||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
// 端点按名剥离(它不是凭据,模式匹配够不着);凭据全部交给
|
||||||
|
// `is_sensitive_config_key` 统一模式匹配(与 Claude 提取器一致)。
|
||||||
|
// 只列固定名单会漏掉下一个 `*_API_KEY` —— 例如 `GOOGLE_API_KEY`
|
||||||
|
// (provider.rs 认可的一等 Gemini 凭据),而共享片段会被 deep-merge
|
||||||
|
// 回其它 Gemini 供应商,漏剥即等于把 A 账号的密钥写进 B 供应商并
|
||||||
|
// 发往 B 的 base_url。`GEMINI_API_KEY` 不必单列:`_KEY` 后缀已覆盖。
|
||||||
|
if key == "GOOGLE_GEMINI_BASE_URL" || Self::is_sensitive_config_key(key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Value::String(v) = value else {
|
let Value::String(v) = value else {
|
||||||
@@ -3263,6 +3751,221 @@ impl ProviderService {
|
|||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一次性清理:把历史泄漏进 Gemini 共享片段的凭据从所有存储位置抹掉。
|
||||||
|
///
|
||||||
|
/// 背景:`extract_gemini_common_config` 曾只剥离两个固定键名,`GOOGLE_API_KEY`
|
||||||
|
/// 等一等凭据会进入共享片段,再被 `apply_common_config_to_settings` 深合并进
|
||||||
|
/// **其它** Gemini 供应商的 env,随请求发往对方的 base_url。
|
||||||
|
///
|
||||||
|
/// 光修提取器不够:Gemini 的片段一旦生成就**永不自动重提取**(启动期
|
||||||
|
/// auto-extract 与导入后补提取都要求 `snippet.is_none()`,切换时的回写又只对
|
||||||
|
/// Claude / Codex 生效),所以存量片段会一直带着密钥继续注入。
|
||||||
|
///
|
||||||
|
/// 两个关键约束:
|
||||||
|
///
|
||||||
|
/// 1. **不能只清片段**。合并与剥离是一对靠「值相等」严格抵消的操作:切走供应商时
|
||||||
|
/// `remove_common_config_from_settings` 依据片段内容把注入的键删掉。片段里一旦
|
||||||
|
/// 没了这个键,backfill 就会把 live 中残留的密钥原样写进受害供应商的
|
||||||
|
/// `settings_config`——泄漏从瞬时污染变成永久污染。所以片段、各供应商配置、
|
||||||
|
/// live 文件必须一起清。
|
||||||
|
/// 2. **按值相等定向删除,不按键名一刀切**。复用 `remove_common_config_from_settings`
|
||||||
|
/// 可以只清掉扩散出去的那一份,保留某个供应商自己写的、值不同的同名键。
|
||||||
|
///
|
||||||
|
/// 步骤顺序本身是安全属性的一部分:**清片段必须排在最后**。片段是
|
||||||
|
/// `remove_common_config_from_settings` 唯一的"该剥哪些键"来源,一旦清空,任何
|
||||||
|
/// 残留(live 文件里的、下一轮重试要处理的)都再也无法被识别和剥离。所以所有
|
||||||
|
/// 可能失败的步骤都排在它前面,失败即带错返回,让下次启动能原样重来。
|
||||||
|
///
|
||||||
|
/// 清理后部分供应商会显示缺少 API Key,需用户重填——这是正确行为:那把密钥本就
|
||||||
|
/// 不属于它们。(受害者原有的同名键在合并时已被覆盖,无法恢复。)动手前会往
|
||||||
|
/// settings 的 `gemini_common_config_scrub_audit_v1` 写一条审计记录,内容是
|
||||||
|
/// **键名与受影响的供应商 id,不含值**:`settings` 会随 WebDAV/S3 同步上传,
|
||||||
|
/// 而这里处理的正是必须销毁的凭据,留值等于把一次清除换成一份跨设备扩散、
|
||||||
|
/// 没有界面入口、永不过期的明文副本。
|
||||||
|
pub async fn scrub_leaked_gemini_common_config(state: &AppState) -> Result<(), AppError> {
|
||||||
|
const FLAG: &str = "gemini_common_config_credentials_scrubbed_v1";
|
||||||
|
const AUDIT_KEY: &str = "gemini_common_config_scrub_audit_v1";
|
||||||
|
let app = AppType::Gemini;
|
||||||
|
|
||||||
|
if state.db.get_bool_flag(FLAG).unwrap_or(false) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(snippet_text) = state.db.get_config_snippet(app.as_str())? else {
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 片段解析不了就不动它,只标记完成——乱改用户数据比留着更糟
|
||||||
|
let Ok(Value::Object(entries)) = serde_json::from_str::<Value>(&snippet_text) else {
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut poison = serde_json::Map::new();
|
||||||
|
let mut clean = serde_json::Map::new();
|
||||||
|
for (key, value) in entries {
|
||||||
|
if Self::is_sensitive_config_key(&key) {
|
||||||
|
poison.insert(key, value);
|
||||||
|
} else {
|
||||||
|
clean.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if poison.is_empty() {
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"检测到 {} 个凭据键残留在 Gemini 通用配置片段中,开始一次性清理",
|
||||||
|
poison.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let poison_keys: Vec<String> = poison.keys().cloned().collect();
|
||||||
|
let poison_value = Value::Object(poison);
|
||||||
|
let poison_text = serde_json::to_string(&poison_value)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
|
||||||
|
// 1) 先算出各供应商清理后的配置,但**先不落库**
|
||||||
|
let providers = state.db.get_all_providers(app.as_str())?;
|
||||||
|
let mut pending: Vec<(String, Provider, Value)> = Vec::new();
|
||||||
|
for (id, provider) in providers {
|
||||||
|
let cleaned = match live::remove_common_config_from_settings(
|
||||||
|
&app,
|
||||||
|
&provider.settings_config,
|
||||||
|
&poison_text,
|
||||||
|
) {
|
||||||
|
Ok(cleaned) => cleaned,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("清理供应商 '{id}' 的泄漏凭据失败: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if cleaned != provider.settings_config {
|
||||||
|
pending.push((id, provider, cleaned));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 落库前留一份审计记录:**只记键名与受影响的供应商,不记值**。
|
||||||
|
//
|
||||||
|
// 「按值相等定向删除」在一种合法场景下也会命中:用户有意在多个供应商里
|
||||||
|
// 复用同一把 key。所以必须留下"删了什么、从哪删的",否则用户只能靠翻
|
||||||
|
// 日志。但不能留值——`settings` 表不在 `SYNC_SKIP_TABLES` 里,会随
|
||||||
|
// WebDAV/S3 同步上传,而这里处理的恰恰是必须销毁的泄漏凭据:留值等于
|
||||||
|
// 把一次清除换成一份没有界面入口、永不过期、还会跨设备扩散的明文副本。
|
||||||
|
// 密钥本来就该轮换,可恢复性不值这个代价。
|
||||||
|
let removed_env_keys = |before: &Value, after: &Value| -> Vec<String> {
|
||||||
|
let before_env = before.get("env").and_then(Value::as_object);
|
||||||
|
let after_env = after.get("env").and_then(Value::as_object);
|
||||||
|
match (before_env, after_env) {
|
||||||
|
(Some(before_env), Some(after_env)) => before_env
|
||||||
|
.keys()
|
||||||
|
.filter(|key| !after_env.contains_key(*key))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
(Some(before_env), None) => before_env.keys().cloned().collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let audit = serde_json::json!({
|
||||||
|
"removedFromSnippet": poison_keys,
|
||||||
|
"providers": pending
|
||||||
|
.iter()
|
||||||
|
.map(|(id, provider, cleaned)| serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"removedKeys": removed_env_keys(&provider.settings_config, cleaned),
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
let audit_text = serde_json::to_string(&audit)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
// 只在没有记录时写。provider 的写入不是一个事务(每次 save_provider 各自
|
||||||
|
// 提交),上一轮可能改到一半就中止;此时完成标记没置位,下次启动会重跑,
|
||||||
|
// 而重跑看到的"原始状态"已经残缺。无条件 INSERT OR REPLACE 会拿这份残缺
|
||||||
|
// 记录盖掉第一轮那份完整的。
|
||||||
|
if state.db.get_setting(AUDIT_KEY)?.is_none() {
|
||||||
|
state.db.set_setting(AUDIT_KEY, &audit_text)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 各供应商 settings_config:按值相等定向删除扩散出去的副本
|
||||||
|
for (id, provider, cleaned) in pending {
|
||||||
|
let mut updated = provider;
|
||||||
|
updated.settings_config = cleaned;
|
||||||
|
state.db.save_provider(app.as_str(), &updated)?;
|
||||||
|
log::info!("已从 Gemini 供应商 '{id}' 中清除泄漏的共享凭据");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 代理接管中的 live 快照里也可能有一份副本。这一步的失败**必须传播**:
|
||||||
|
//
|
||||||
|
// 关代理时 `restore_live_config_for_app_with_fallback_inner`(proxy.rs:869)
|
||||||
|
// 会把这份快照原样写回 `~/.gemini/.env`。若它仍带毒而我们照样清了片段、置了
|
||||||
|
// 完成标记,那么代理一停凭据就当场复活,而一次性标记又保证不会再清第二次;
|
||||||
|
// 此后片段里已没有这个键,下一次切换的 backfill 就把它永久写进受害供应商的
|
||||||
|
// 配置——还是本函数开头那个顺序陷阱,只是换了扇门进来。
|
||||||
|
//
|
||||||
|
// 带错返回是安全的失败方式:调用方(lib.rs:1189)只记 warn 不中断启动,
|
||||||
|
// 片段和标记都原样留着,下次启动照原样重来。
|
||||||
|
if let Some(backup) = state.db.get_live_backup(app.as_str()).await? {
|
||||||
|
let original: Value = serde_json::from_str(&backup.original_config)
|
||||||
|
.map_err(|e| AppError::Message(format!("解析 Gemini 代理接管备份失败: {e}")))?;
|
||||||
|
let cleaned = live::remove_common_config_from_settings(&app, &original, &poison_text)?;
|
||||||
|
if cleaned != original {
|
||||||
|
let text = serde_json::to_string(&cleaned)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
state.db.save_live_backup(app.as_str(), &text).await?;
|
||||||
|
log::info!("已从 Gemini 代理接管备份中清除泄漏的共享凭据");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) `~/.gemini/.env`:**定向**删除,且必须在清片段之前做,失败即中止。
|
||||||
|
//
|
||||||
|
// 为什么不用 `sync_current_provider_for_app` 重投影:它在没有当前供应商
|
||||||
|
// 时直接返回 Ok 而根本不写文件,泄漏值会原样留在 live 里;等片段被清空
|
||||||
|
// 之后,下次切换时 `remove_common_config_from_settings` 再也认不出这个
|
||||||
|
// 键,backfill 就把它永久写进受害供应商的配置——正是本函数开头说的那个
|
||||||
|
// 顺序陷阱,只是由"没修"变成"修了一半更糟"。定向删除还顺带保住了只存在
|
||||||
|
// 于 live、与供应商无关的手工 env(重投影会把它们抹掉)。
|
||||||
|
//
|
||||||
|
// 删除走 `remove_gemini_env_entries` 的**保序**实现而不是 read→HashMap→
|
||||||
|
// write 往返:后者会顺手抹掉注释、空行和无法识别的行,并按键名重排整个
|
||||||
|
// 文件。全量投影时那无所谓,但这里是一次用户没主动触发的启动期清理,不该
|
||||||
|
// 连带改写与泄漏无关的内容。
|
||||||
|
//
|
||||||
|
// 失败就带着错误返回:片段此刻还留着毒键,完成标记也没置位,下次启动能
|
||||||
|
// 照原样重来。清片段是不可逆的一步,必须排在所有会失败的步骤之后。
|
||||||
|
let poison_env: HashMap<String, String> = poison_value
|
||||||
|
.as_object()
|
||||||
|
.map(|map| {
|
||||||
|
map.iter()
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
value.as_str().map(|text| (key.clone(), text.to_string()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if crate::gemini_config::remove_gemini_env_entries(&poison_env)? {
|
||||||
|
log::info!("已从 ~/.gemini/.env 中清除泄漏的共享凭据");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 片段本身:保留可共享的部分。全部清空时删行而不是写 "{}"——留着空行会让
|
||||||
|
// should_auto_extract_config_snippet 永远为 false,用户的合法共享配置再也
|
||||||
|
// 重建不回来。同理绝不置 cleared 标记。
|
||||||
|
if clean.is_empty() {
|
||||||
|
state.db.set_config_snippet(app.as_str(), None)?;
|
||||||
|
} else {
|
||||||
|
let cleaned_snippet = serde_json::to_string_pretty(&Value::Object(clean))
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_config_snippet(app.as_str(), Some(cleaned_snippet))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
log::info!("Gemini 通用配置凭据清理完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract common config for OpenCode (JSON format)
|
/// Extract common config for OpenCode (JSON format)
|
||||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||||
// OpenCode uses a different config structure with npm, options, models
|
// OpenCode uses a different config structure with npm, options, models
|
||||||
|
|||||||
+1432
-120
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,77 @@ import { configApi } from "@/lib/api";
|
|||||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
||||||
|
|
||||||
|
/** 供应商专属、不可共享的键(端点与主凭据),按名单剥离。 */
|
||||||
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||||
"GOOGLE_GEMINI_BASE_URL",
|
"GOOGLE_GEMINI_BASE_URL",
|
||||||
"GEMINI_API_KEY",
|
"GEMINI_API_KEY",
|
||||||
] as const;
|
] as const;
|
||||||
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
|
||||||
|
/**
|
||||||
|
* 凭据键的模式匹配,对应后端 `ProviderService::is_sensitive_config_key`。
|
||||||
|
*
|
||||||
|
* 共享片段会被深合并进**其它** Gemini 供应商的 env,所以任何凭据都不能进来。
|
||||||
|
* 固定名单挡不住下一个 `*_API_KEY`(`GOOGLE_API_KEY` 就是这么漏过去的),
|
||||||
|
* 必须用模式覆盖整类。两端判定要一致,否则后端清理完还能从这里手工写回去。
|
||||||
|
*/
|
||||||
|
const SENSITIVE_EXACT = [
|
||||||
|
"APIKEY",
|
||||||
|
"API_KEY",
|
||||||
|
"TOKEN",
|
||||||
|
"SECRET",
|
||||||
|
"PASSWORD",
|
||||||
|
"CREDENTIALS",
|
||||||
|
];
|
||||||
|
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但不误伤复数 `_TOKENS`(那是正常配置)
|
||||||
|
const SENSITIVE_SUFFIXES = [
|
||||||
|
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),必须单列:
|
||||||
|
// 只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通的那一种漏在外面。
|
||||||
|
"_KEY",
|
||||||
|
"_API_KEY",
|
||||||
|
"_ACCESS_KEY",
|
||||||
|
"_ACCESS_KEY_ID",
|
||||||
|
"_KEY_ID",
|
||||||
|
"_PRIVATE_KEY",
|
||||||
|
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`。
|
||||||
|
"_APIKEY",
|
||||||
|
"_ACCESSKEY",
|
||||||
|
"_SECRETKEY",
|
||||||
|
"_APITOKEN",
|
||||||
|
"_AUTH_TOKEN",
|
||||||
|
"_TOKEN",
|
||||||
|
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
|
||||||
|
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
|
||||||
|
"_PAT",
|
||||||
|
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`,`_PWD` 不会误伤 PWD / OLDPWD。
|
||||||
|
"_PWD",
|
||||||
|
"_PASS",
|
||||||
|
"_PASSPHRASE",
|
||||||
|
"_CREDS",
|
||||||
|
];
|
||||||
|
const SENSITIVE_CONTAINS = [
|
||||||
|
"SECRET",
|
||||||
|
"PASSWORD",
|
||||||
|
"PASSWD",
|
||||||
|
"CREDENTIAL",
|
||||||
|
"PRIVATE_KEY",
|
||||||
|
"BEARER_TOKEN",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isForbiddenCommonEnvKey(name: string): boolean {
|
||||||
|
if (
|
||||||
|
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
|
||||||
|
name as (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const upper = name.toUpperCase();
|
||||||
|
return (
|
||||||
|
SENSITIVE_EXACT.includes(upper) ||
|
||||||
|
SENSITIVE_SUFFIXES.some((suffix) => upper.endsWith(suffix)) ||
|
||||||
|
SENSITIVE_CONTAINS.some((part) => upper.includes(part))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface UseGeminiCommonConfigProps {
|
interface UseGeminiCommonConfigProps {
|
||||||
envValue: string;
|
envValue: string;
|
||||||
@@ -90,9 +156,7 @@ export function useGeminiCommonConfig({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(parsed);
|
const keys = Object.keys(parsed);
|
||||||
const forbiddenKeys = keys.filter((key) =>
|
const forbiddenKeys = keys.filter(isForbiddenCommonEnvKey);
|
||||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
|
||||||
);
|
|
||||||
if (forbiddenKeys.length > 0) {
|
if (forbiddenKeys.length > 0) {
|
||||||
return {
|
return {
|
||||||
env: {},
|
env: {},
|
||||||
|
|||||||
@@ -0,0 +1,668 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { settingsApi } from "@/lib/api/settings";
|
||||||
|
import { usageApi } from "@/lib/api/usage";
|
||||||
|
import {
|
||||||
|
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||||
|
syncModelsDevPricing,
|
||||||
|
} from "@/lib/modelsDevAutoSync";
|
||||||
|
import {
|
||||||
|
fetchModelsDevPricing,
|
||||||
|
flattenModels,
|
||||||
|
formatPrice,
|
||||||
|
getCommonModelKeys,
|
||||||
|
type ModelsDevEntry,
|
||||||
|
} from "@/lib/modelsDevPricing";
|
||||||
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
|
import type { ModelsDevSyncConfig, ModelsDevSyncState } from "@/types/usage";
|
||||||
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||||
|
|
||||||
|
const MODELS_DEV_QUERY_KEY = ["models-dev-pricing"] as const;
|
||||||
|
const DEFAULT_VISIBLE_ROWS = 80;
|
||||||
|
const MAX_VISIBLE_ROWS = 300;
|
||||||
|
|
||||||
|
interface AutoSyncDialogProps {
|
||||||
|
state: ModelsDevSyncState;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (state: ModelsDevSyncState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoSyncDialog({ state, onClose, onSaved }: AutoSyncDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [providerFilter, setProviderFilter] = useState("all");
|
||||||
|
const [includeCommonModels, setIncludeCommonModels] = useState(
|
||||||
|
state.config.includeCommonModels,
|
||||||
|
);
|
||||||
|
const [selectedModelKeys, setSelectedModelKeys] = useState(
|
||||||
|
() => new Set(state.config.selectedModelKeys),
|
||||||
|
);
|
||||||
|
const [excludedCommonModelKeys, setExcludedCommonModelKeys] = useState(
|
||||||
|
() => new Set(state.config.excludedCommonModelKeys),
|
||||||
|
);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: MODELS_DEV_QUERY_KEY,
|
||||||
|
queryFn: fetchModelsDevPricing,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
|
||||||
|
const commonModelKeys = useMemo(() => getCommonModelKeys(entries), [entries]);
|
||||||
|
|
||||||
|
const effectiveSelectedKeys = useMemo(() => {
|
||||||
|
const selected = new Set(selectedModelKeys);
|
||||||
|
if (includeCommonModels) {
|
||||||
|
for (const key of commonModelKeys) {
|
||||||
|
if (!excludedCommonModelKeys.has(key)) selected.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}, [
|
||||||
|
commonModelKeys,
|
||||||
|
excludedCommonModelKeys,
|
||||||
|
includeCommonModels,
|
||||||
|
selectedModelKeys,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const providers = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!map.has(entry.providerId)) {
|
||||||
|
map.set(entry.providerId, entry.providerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const isFiltering = search.trim() !== "" || providerFilter !== "all";
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
return entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
(providerFilter === "all" || entry.providerId === providerFilter) &&
|
||||||
|
(!query ||
|
||||||
|
entry.modelId.toLowerCase().includes(query) ||
|
||||||
|
entry.normalizedId.includes(query) ||
|
||||||
|
entry.modelName.toLowerCase().includes(query) ||
|
||||||
|
entry.providerName.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
}, [entries, providerFilter, search]);
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
|
||||||
|
[filtered, isFiltering],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleEntry = (entry: ModelsDevEntry) => {
|
||||||
|
const isSelected = effectiveSelectedKeys.has(entry.key);
|
||||||
|
setSelectedModelKeys((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (isSelected) next.delete(entry.key);
|
||||||
|
else next.add(entry.key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setExcludedCommonModelKeys((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (isSelected && includeCommonModels && commonModelKeys.has(entry.key)) {
|
||||||
|
next.add(entry.key);
|
||||||
|
} else {
|
||||||
|
next.delete(entry.key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFiltered = () => {
|
||||||
|
setSelectedModelKeys((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
for (const entry of filtered) next.add(entry.key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setExcludedCommonModelKeys((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
for (const entry of filtered) next.delete(entry.key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFiltered = () => {
|
||||||
|
setSelectedModelKeys((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
for (const entry of filtered) next.delete(entry.key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (includeCommonModels) {
|
||||||
|
setExcludedCommonModelKeys((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
for (const entry of filtered) {
|
||||||
|
if (commonModelKeys.has(entry.key)) next.add(entry.key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const config: ModelsDevSyncConfig = {
|
||||||
|
...state.config,
|
||||||
|
includeCommonModels,
|
||||||
|
selectedModelKeys: Array.from(selectedModelKeys).sort(),
|
||||||
|
excludedCommonModelKeys: Array.from(excludedCommonModelKeys).sort(),
|
||||||
|
};
|
||||||
|
await usageApi.saveModelsDevSyncConfig(config);
|
||||||
|
onSaved({ ...state, config });
|
||||||
|
toast.success(t("usage.modelsDevAutoSync.selectionSaved"));
|
||||||
|
onClose();
|
||||||
|
} catch (saveError) {
|
||||||
|
toast.error(String(saveError));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const priceColumns = (entry: ModelsDevEntry) =>
|
||||||
|
[
|
||||||
|
{ label: t("usage.inputCost"), value: entry.input },
|
||||||
|
{ label: t("usage.outputCost"), value: entry.output },
|
||||||
|
{ label: t("usage.cacheReadCost"), value: entry.cacheRead },
|
||||||
|
{ label: t("usage.cacheWriteCost"), value: entry.cacheWrite },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && !isSaving && onClose()}>
|
||||||
|
<DialogContent
|
||||||
|
zIndex="top"
|
||||||
|
className="max-w-4xl h-[84vh]"
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (isTextEditableTarget(event.target)) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("usage.modelsDevAutoSync.configureTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("usage.modelsDevAutoSync.configureDescription")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{t("usage.modelsDevAutoSync.commonModels")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("usage.modelsDevAutoSync.commonModelsDescription", {
|
||||||
|
count: commonModelKeys.size,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={includeCommonModels}
|
||||||
|
onCheckedChange={setIncludeCommonModels}
|
||||||
|
aria-label={t("usage.modelsDevAutoSync.commonModels")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription className="flex items-center justify-between gap-3">
|
||||||
|
<span>
|
||||||
|
{t("usage.modelsDevLoadError")}: {String(error)}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
{t("usage.modelsDevRetry")}
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={providerFilter}
|
||||||
|
onValueChange={setProviderFilter}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-48 shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[120] max-h-[min(24rem,var(--radix-select-content-available-height))]">
|
||||||
|
<SelectItem value="all">
|
||||||
|
{t("usage.modelsDevAllProviders")}
|
||||||
|
</SelectItem>
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<SelectItem key={provider.id} value={provider.id}>
|
||||||
|
{provider.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder={t("usage.modelsDevSearchPlaceholder")}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={selectFiltered}
|
||||||
|
disabled={filtered.length === 0}
|
||||||
|
>
|
||||||
|
{t("usage.modelsDevAutoSync.selectFiltered", {
|
||||||
|
count: filtered.length,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFiltered}
|
||||||
|
disabled={filtered.length === 0}
|
||||||
|
>
|
||||||
|
{t("usage.modelsDevAutoSync.clearFiltered")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("usage.modelsDevAutoSync.selectedCount", {
|
||||||
|
count: effectiveSelectedKeys.size,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>{t("usage.modelsDevAutoSync.selectionHint")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border border-border/50">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("usage.modelsDevNoResults")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/30">
|
||||||
|
{visible.map((entry) => {
|
||||||
|
const selected = effectiveSelectedKeys.has(entry.key);
|
||||||
|
const common = commonModelKeys.has(entry.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => toggleEntry(entry)}
|
||||||
|
className={`flex w-full items-center gap-3 px-3 py-2 text-left ${
|
||||||
|
selected ? "bg-accent/50" : "hover:bg-muted/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-muted-foreground/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected && <Check className="h-3 w-3" />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{entry.modelName}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{entry.providerName}
|
||||||
|
</span>
|
||||||
|
{common && (
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
|
||||||
|
{t("usage.modelsDevAutoSync.commonBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.releaseDate && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||||
|
{entry.releaseDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate font-mono text-xs text-muted-foreground"
|
||||||
|
title={entry.modelId}
|
||||||
|
>
|
||||||
|
{entry.normalizedId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-3 text-right">
|
||||||
|
{priceColumns(entry).map((column) => (
|
||||||
|
<div key={column.label} className="w-16">
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{column.label}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs">
|
||||||
|
${formatPrice(column.value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length > visible.length && (
|
||||||
|
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
|
||||||
|
{isFiltering
|
||||||
|
? t("usage.modelsDevTruncated", {
|
||||||
|
shown: visible.length,
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
: t("usage.modelsDevDefaultHint", {
|
||||||
|
shown: visible.length,
|
||||||
|
total: filtered.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={save} disabled={isSaving || isLoading || !!error}>
|
||||||
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelsDevAutoSyncPanel() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [isReloading, setIsReloading] = useState(false);
|
||||||
|
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||||
|
queryFn: usageApi.getModelsDevSyncConfig,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCachedState = (state: ModelsDevSyncState) => {
|
||||||
|
queryClient.setQueryData(MODELS_DEV_SYNC_CONFIG_QUERY_KEY, state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = async (config: ModelsDevSyncConfig) => {
|
||||||
|
if (!data) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await usageApi.saveModelsDevSyncConfig(config);
|
||||||
|
updateCachedState({ ...data, config });
|
||||||
|
} catch (saveError) {
|
||||||
|
toast.error(String(saveError));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncNow = async () => {
|
||||||
|
if (!data) return;
|
||||||
|
setIsSyncing(true);
|
||||||
|
try {
|
||||||
|
const result = await syncModelsDevPricing(data, true);
|
||||||
|
await Promise.all([
|
||||||
|
refetch(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
|
||||||
|
]);
|
||||||
|
toast.success(
|
||||||
|
t("usage.modelsDevAutoSync.syncSuccess", {
|
||||||
|
imported: result.imported,
|
||||||
|
changed: result.changed,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (syncError) {
|
||||||
|
await refetch();
|
||||||
|
toast.error(
|
||||||
|
t("usage.modelsDevAutoSync.syncFailed", { error: String(syncError) }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadLocalFile = async () => {
|
||||||
|
setIsReloading(true);
|
||||||
|
try {
|
||||||
|
await usageApi.getModelPricing();
|
||||||
|
await Promise.all([
|
||||||
|
refetch(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
|
||||||
|
]);
|
||||||
|
toast.success(t("usage.modelsDevAutoSync.localFileReloaded"));
|
||||||
|
} catch (reloadError) {
|
||||||
|
toast.error(
|
||||||
|
t("usage.modelsDevAutoSync.localFileReloadFailed", {
|
||||||
|
error: String(reloadError),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsReloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLocalFileFolder = async () => {
|
||||||
|
try {
|
||||||
|
await settingsApi.openAppConfigFolder();
|
||||||
|
} catch (openError) {
|
||||||
|
toast.error(
|
||||||
|
t("usage.modelsDevAutoSync.openFolderFailed", {
|
||||||
|
error: String(openError),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center rounded-lg border border-border/50 py-6">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription className="flex items-center justify-between gap-3">
|
||||||
|
<span>
|
||||||
|
{t("usage.modelsDevAutoSync.configLoadFailed", {
|
||||||
|
error: String(error),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
{t("usage.modelsDevRetry")}
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSync = data.config.lastSyncAt
|
||||||
|
? new Date(data.config.lastSyncAt).toLocaleString(i18n.resolvedLanguage)
|
||||||
|
: t("usage.modelsDevAutoSync.neverSynced");
|
||||||
|
|
||||||
|
const handleAutoSyncChange = (autoSyncEnabled: boolean) => {
|
||||||
|
if (autoSyncEnabled) {
|
||||||
|
setShowEnableConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void saveConfig({ ...data.config, autoSyncEnabled: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/15 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium">
|
||||||
|
{t("usage.modelsDevAutoSync.title")}
|
||||||
|
</h5>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{t("usage.modelsDevAutoSync.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{data.config.autoSyncEnabled
|
||||||
|
? t("usage.modelsDevAutoSync.enabled")
|
||||||
|
: t("usage.modelsDevAutoSync.disabled")}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={data.config.autoSyncEnabled}
|
||||||
|
disabled={isSaving}
|
||||||
|
onCheckedChange={handleAutoSyncChange}
|
||||||
|
aria-label={t("usage.modelsDevAutoSync.title")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 text-xs text-muted-foreground md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
{t("usage.modelsDevAutoSync.lastSync")}: {lastSync}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("usage.modelsDevAutoSync.commonStatus")}:{" "}
|
||||||
|
{data.config.includeCommonModels
|
||||||
|
? t("usage.modelsDevAutoSync.enabled")
|
||||||
|
: t("usage.modelsDevAutoSync.disabled")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.config.lastSyncError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{t("usage.modelsDevAutoSync.lastError", {
|
||||||
|
error: data.config.lastSyncError,
|
||||||
|
})}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md bg-background/60 px-3 py-2">
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
{t("usage.modelsDevAutoSync.localFile")}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-xs" title={data.configPath}>
|
||||||
|
{data.configPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void openLocalFileFolder()}
|
||||||
|
>
|
||||||
|
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{t("usage.modelsDevAutoSync.openFolder")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void reloadLocalFile()}
|
||||||
|
disabled={isReloading}
|
||||||
|
>
|
||||||
|
{isReloading ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("usage.modelsDevAutoSync.reloadLocalFile")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{t("usage.modelsDevAutoSync.configure")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void syncNow()} disabled={isSyncing}>
|
||||||
|
{isSyncing ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("usage.modelsDevAutoSync.syncNow")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDialogOpen && (
|
||||||
|
<AutoSyncDialog
|
||||||
|
state={data}
|
||||||
|
onClose={() => setIsDialogOpen(false)}
|
||||||
|
onSaved={updateCachedState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showEnableConfirm}
|
||||||
|
title={t("usage.modelsDevAutoSync.enableConfirmTitle")}
|
||||||
|
message={t("usage.modelsDevAutoSync.enableConfirmMessage")}
|
||||||
|
confirmText={t("usage.modelsDevAutoSync.enableConfirmAction")}
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowEnableConfirm(false);
|
||||||
|
void saveConfig({ ...data.config, autoSyncEnabled: true });
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowEnableConfirm(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,114 +22,24 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useUpdateModelPricing } from "@/lib/query/usage";
|
import { useUpdateModelPricing } from "@/lib/query/usage";
|
||||||
|
import {
|
||||||
|
fetchModelsDevPricing,
|
||||||
|
flattenModels,
|
||||||
|
formatPrice,
|
||||||
|
type ModelsDevEntry,
|
||||||
|
} from "@/lib/modelsDevPricing";
|
||||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||||
|
|
||||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
export {
|
||||||
|
flattenModels,
|
||||||
|
formatPrice,
|
||||||
|
normalizeModelIdForPricing,
|
||||||
|
} from "@/lib/modelsDevPricing";
|
||||||
|
|
||||||
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
|
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
|
||||||
const DEFAULT_VISIBLE_ROWS = 50;
|
const DEFAULT_VISIBLE_ROWS = 50;
|
||||||
const MAX_VISIBLE_ROWS = 200;
|
const MAX_VISIBLE_ROWS = 200;
|
||||||
|
|
||||||
interface ModelsDevCost {
|
|
||||||
input?: number;
|
|
||||||
output?: number;
|
|
||||||
cache_read?: number;
|
|
||||||
cache_write?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelsDevModel {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
release_date?: string;
|
|
||||||
cost?: ModelsDevCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelsDevProvider {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
models?: Record<string, ModelsDevModel>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelsDevResponse = Record<string, ModelsDevProvider>;
|
|
||||||
|
|
||||||
interface ModelsDevEntry {
|
|
||||||
/** providerId/modelId,同一模型可能出现在多个供应商下 */
|
|
||||||
key: string;
|
|
||||||
providerId: string;
|
|
||||||
providerName: string;
|
|
||||||
modelId: string;
|
|
||||||
/** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */
|
|
||||||
normalizedId: string;
|
|
||||||
modelName: string;
|
|
||||||
/** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */
|
|
||||||
releaseDate: string;
|
|
||||||
input: number;
|
|
||||||
output: number;
|
|
||||||
cacheRead: number;
|
|
||||||
cacheWrite: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 与后端 clean_model_id_for_pricing(usage_stats.rs)保持一致:
|
|
||||||
* 取最后一个 '/' 之后的段、去掉 ':' 后缀、'@' 换成 '-'、转小写、去掉 [1m] 标记。
|
|
||||||
* 成本归因查询用的就是这种归一化形式,原样入库的 ID 永远匹配不上。
|
|
||||||
*/
|
|
||||||
export function normalizeModelIdForPricing(modelId: string): string {
|
|
||||||
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
|
|
||||||
const beforeColon = afterSlash.split(":")[0] ?? "";
|
|
||||||
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
|
|
||||||
if (normalized.endsWith("[1m]")) {
|
|
||||||
normalized = normalized.slice(0, -"[1m]".length).trim();
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */
|
|
||||||
export function formatPrice(value: number): string {
|
|
||||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
|
||||||
// toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 0 处理
|
|
||||||
if (value >= 1e12) return "0";
|
|
||||||
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
|
||||||
return trimmed || "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
|
|
||||||
const entries: ModelsDevEntry[] = [];
|
|
||||||
for (const [providerId, provider] of Object.entries(data)) {
|
|
||||||
if (!provider || typeof provider !== "object") continue;
|
|
||||||
const providerName = provider.name || providerId;
|
|
||||||
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
|
||||||
const cost = model?.cost;
|
|
||||||
const input = typeof cost?.input === "number" ? cost.input : null;
|
|
||||||
const output = typeof cost?.output === "number" ? cost.output : null;
|
|
||||||
if (input === null && output === null) continue;
|
|
||||||
const normalizedId = normalizeModelIdForPricing(modelId);
|
|
||||||
if (!normalizedId) continue;
|
|
||||||
entries.push({
|
|
||||||
key: `${providerId}/${modelId}`,
|
|
||||||
providerId,
|
|
||||||
providerName,
|
|
||||||
modelId,
|
|
||||||
normalizedId,
|
|
||||||
modelName: model?.name || modelId,
|
|
||||||
releaseDate:
|
|
||||||
typeof model?.release_date === "string" ? model.release_date : "",
|
|
||||||
input: input ?? 0,
|
|
||||||
output: output ?? 0,
|
|
||||||
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
|
|
||||||
cacheWrite:
|
|
||||||
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 最新发布的排在前面
|
|
||||||
entries.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.releaseDate.localeCompare(a.releaseDate) ||
|
|
||||||
a.modelName.localeCompare(b.modelName),
|
|
||||||
);
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelsDevPickerDialogProps {
|
interface ModelsDevPickerDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -160,13 +70,7 @@ export function ModelsDevPickerDialog({
|
|||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ["models-dev-pricing"],
|
queryKey: ["models-dev-pricing"],
|
||||||
queryFn: async (): Promise<ModelsDevResponse> => {
|
queryFn: fetchModelsDevPricing,
|
||||||
const res = await fetch(MODELS_DEV_API_URL);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
enabled: open,
|
enabled: open,
|
||||||
staleTime: 60 * 60 * 1000,
|
staleTime: 60 * 60 * 1000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
|
|||||||
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { proxyApi } from "@/lib/api/proxy";
|
import { proxyApi } from "@/lib/api/proxy";
|
||||||
|
import { ModelsDevAutoSyncPanel } from "./ModelsDevAutoSyncPanel";
|
||||||
|
|
||||||
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
|
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
|
||||||
type PricingApp = (typeof PRICING_APPS)[number];
|
type PricingApp = (typeof PRICING_APPS)[number];
|
||||||
@@ -341,6 +342,8 @@ export function PricingConfigPanel() {
|
|||||||
|
|
||||||
{/* 模型定价配置 */}
|
{/* 模型定价配置 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<ModelsDevAutoSyncPanel />
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||||
|
|||||||
@@ -1633,6 +1633,40 @@
|
|||||||
"modelsDevNoResults": "No matching models",
|
"modelsDevNoResults": "No matching models",
|
||||||
"modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search",
|
"modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search",
|
||||||
"modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all",
|
"modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all",
|
||||||
|
"modelsDevAutoSync": {
|
||||||
|
"title": "Automatic models.dev pricing sync",
|
||||||
|
"description": "When enabled, periodically fetch selected prices when CC Switch launches and keep them in your local pricing file.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"enableConfirmTitle": "Enable automatic pricing sync?",
|
||||||
|
"enableConfirmMessage": "After enabling, CC Switch will periodically update prices for the selected models from models.dev when it launches (at most once every 6 hours). Built-in and manually configured prices with the same model ID will be overwritten.",
|
||||||
|
"enableConfirmAction": "Enable automatic sync",
|
||||||
|
"configure": "Choose models",
|
||||||
|
"configureTitle": "Choose models for automatic pricing sync",
|
||||||
|
"configureDescription": "Search and filter models to update automatically. Your choices are stored locally.",
|
||||||
|
"commonModels": "Automatically include common models",
|
||||||
|
"commonModelsDescription": "Selects {{count}} recent Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM models.",
|
||||||
|
"selectFiltered": "Select filtered ({{count}})",
|
||||||
|
"clearFiltered": "Clear filtered",
|
||||||
|
"selectedCount": "{{count}} models selected",
|
||||||
|
"selectionHint": "Selections with the same normalized model ID are imported once.",
|
||||||
|
"commonBadge": "Common",
|
||||||
|
"selectionSaved": "Automatic pricing selection saved",
|
||||||
|
"syncSuccess": "Pricing sync complete: {{imported}} models checked, {{changed}} updated",
|
||||||
|
"syncFailed": "Pricing sync failed: {{error}}",
|
||||||
|
"configLoadFailed": "Failed to load local pricing configuration: {{error}}",
|
||||||
|
"neverSynced": "Never",
|
||||||
|
"lastSync": "Last sync",
|
||||||
|
"commonStatus": "Common models",
|
||||||
|
"lastError": "Last automatic sync failed: {{error}}",
|
||||||
|
"localFile": "Local pricing file",
|
||||||
|
"openFolder": "Open folder",
|
||||||
|
"openFolderFailed": "Failed to open the local pricing folder: {{error}}",
|
||||||
|
"reloadLocalFile": "Reload file",
|
||||||
|
"localFileReloaded": "Local pricing file reloaded",
|
||||||
|
"localFileReloadFailed": "Failed to reload local pricing file: {{error}}",
|
||||||
|
"syncNow": "Sync now"
|
||||||
|
},
|
||||||
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
|
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
|
||||||
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
|
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
|
||||||
},
|
},
|
||||||
@@ -2283,6 +2317,9 @@
|
|||||||
"skillDirNotFound": "Skill directory not found: {{path}}",
|
"skillDirNotFound": "Skill directory not found: {{path}}",
|
||||||
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
|
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
|
||||||
"emptyArchive": "Downloaded archive is empty",
|
"emptyArchive": "Downloaded archive is empty",
|
||||||
|
"invalidRepoRef": "Invalid repository reference: {{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "Archive exceeds the {{limit_mb}} MB extraction limit; aborted",
|
||||||
|
"archiveTooManyEntries": "Archive has too many entries ({{count}}); the limit is {{limit}}",
|
||||||
"downloadFailed": "Download failed: HTTP {{status}}",
|
"downloadFailed": "Download failed: HTTP {{status}}",
|
||||||
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
||||||
"httpError": "HTTP error {{status}}",
|
"httpError": "HTTP error {{status}}",
|
||||||
|
|||||||
@@ -1633,6 +1633,40 @@
|
|||||||
"modelsDevNoResults": "一致するモデルがありません",
|
"modelsDevNoResults": "一致するモデルがありません",
|
||||||
"modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください",
|
"modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください",
|
||||||
"modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます",
|
"modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます",
|
||||||
|
"modelsDevAutoSync": {
|
||||||
|
"title": "models.dev 料金の自動同期",
|
||||||
|
"description": "有効にすると、CC Switch の起動時に選択したモデルの最新料金を定期的に取得し、ローカル料金ファイルに保存します。",
|
||||||
|
"enabled": "有効",
|
||||||
|
"disabled": "無効",
|
||||||
|
"enableConfirmTitle": "料金の自動同期を有効にしますか?",
|
||||||
|
"enableConfirmMessage": "有効にすると、CC Switch の起動時に models.dev から選択したモデルの料金を定期的に更新します(最大 6 時間に 1 回)。同じモデル ID の内蔵料金と手動設定した料金は上書きされます。",
|
||||||
|
"enableConfirmAction": "自動同期を有効にする",
|
||||||
|
"configure": "モデルを選択",
|
||||||
|
"configureTitle": "料金を自動同期するモデルを選択",
|
||||||
|
"configureDescription": "検索とプロバイダーの絞り込みで自動更新するモデルを選択します。選択内容はローカルに保存されます。",
|
||||||
|
"commonModels": "よく使うモデルを自動的に含める",
|
||||||
|
"commonModelsDescription": "最近の Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax、GLM から {{count}} モデルを選択します。",
|
||||||
|
"selectFiltered": "絞り込み結果を選択({{count}})",
|
||||||
|
"clearFiltered": "絞り込み結果を解除",
|
||||||
|
"selectedCount": "{{count}} モデルを選択中",
|
||||||
|
"selectionHint": "正規化後のモデル ID が同じ項目は一度だけインポートされます。",
|
||||||
|
"commonBadge": "よく使う",
|
||||||
|
"selectionSaved": "自動料金同期の選択を保存しました",
|
||||||
|
"syncSuccess": "料金同期が完了しました:{{imported}} モデルを確認、{{changed}} モデルを更新",
|
||||||
|
"syncFailed": "料金同期に失敗しました:{{error}}",
|
||||||
|
"configLoadFailed": "ローカル料金設定の読み込みに失敗しました:{{error}}",
|
||||||
|
"neverSynced": "未同期",
|
||||||
|
"lastSync": "最終同期",
|
||||||
|
"commonStatus": "よく使うモデル",
|
||||||
|
"lastError": "前回の自動同期に失敗しました:{{error}}",
|
||||||
|
"localFile": "ローカル料金ファイル",
|
||||||
|
"openFolder": "フォルダーを開く",
|
||||||
|
"openFolderFailed": "ローカル料金フォルダーを開けませんでした: {{error}}",
|
||||||
|
"reloadLocalFile": "ファイルを再読み込み",
|
||||||
|
"localFileReloaded": "ローカル料金ファイルを再読み込みしました",
|
||||||
|
"localFileReloadFailed": "ローカル料金ファイルの再読み込みに失敗しました:{{error}}",
|
||||||
|
"syncNow": "今すぐ同期"
|
||||||
|
},
|
||||||
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
|
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
|
||||||
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
|
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
|
||||||
},
|
},
|
||||||
@@ -2283,6 +2317,9 @@
|
|||||||
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
||||||
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
|
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
|
||||||
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
||||||
|
"invalidRepoRef": "リポジトリの指定が不正です:{{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "アーカイブが展開上限 {{limit_mb}} MB を超えたため中止しました",
|
||||||
|
"archiveTooManyEntries": "アーカイブのエントリ数が多すぎます({{count}})。上限は {{limit}} です",
|
||||||
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
||||||
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
||||||
"httpError": "HTTP エラー {{status}}",
|
"httpError": "HTTP エラー {{status}}",
|
||||||
|
|||||||
@@ -1604,6 +1604,40 @@
|
|||||||
"modelsDevNoResults": "沒有符合的模型",
|
"modelsDevNoResults": "沒有符合的模型",
|
||||||
"modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍",
|
"modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍",
|
||||||
"modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋",
|
"modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋",
|
||||||
|
"modelsDevAutoSync": {
|
||||||
|
"title": "自動同步 models.dev 定價",
|
||||||
|
"description": "開啟後,CC Switch 會在啟動時定期擷取所選模型的最新價格,並儲存到本機定價檔案。",
|
||||||
|
"enabled": "已開啟",
|
||||||
|
"disabled": "已關閉",
|
||||||
|
"enableConfirmTitle": "開啟自動同步定價?",
|
||||||
|
"enableConfirmMessage": "開啟後,CC Switch 會在啟動時定期從 models.dev 更新所選模型的價格(最多每 6 小時一次)。相同模型 ID 的軟體內建價格和手動設定價格都會被覆寫。",
|
||||||
|
"enableConfirmAction": "開啟自動同步",
|
||||||
|
"configure": "選擇模型",
|
||||||
|
"configureTitle": "選擇自動同步定價的模型",
|
||||||
|
"configureDescription": "透過搜尋和供應商篩選選擇需要自動更新的模型,選擇結果儲存在本機。",
|
||||||
|
"commonModels": "自動包含常用模型",
|
||||||
|
"commonModelsDescription": "自動選擇 {{count}} 個近期 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax 和 GLM 模型。",
|
||||||
|
"selectFiltered": "全選篩選結果({{count}})",
|
||||||
|
"clearFiltered": "清除篩選結果",
|
||||||
|
"selectedCount": "已選擇 {{count}} 個模型",
|
||||||
|
"selectionHint": "標準化模型 ID 相同的選項只會匯入一次。",
|
||||||
|
"commonBadge": "常用",
|
||||||
|
"selectionSaved": "自動定價同步選擇已儲存",
|
||||||
|
"syncSuccess": "定價同步完成:檢查 {{imported}} 個模型,更新 {{changed}} 個",
|
||||||
|
"syncFailed": "定價同步失敗:{{error}}",
|
||||||
|
"configLoadFailed": "載入本機定價設定失敗:{{error}}",
|
||||||
|
"neverSynced": "從未同步",
|
||||||
|
"lastSync": "上次同步",
|
||||||
|
"commonStatus": "常用模型",
|
||||||
|
"lastError": "上次自動同步失敗:{{error}}",
|
||||||
|
"localFile": "本機定價檔案",
|
||||||
|
"openFolder": "開啟資料夾",
|
||||||
|
"openFolderFailed": "開啟本機定價檔案資料夾失敗:{{error}}",
|
||||||
|
"reloadLocalFile": "重新載入檔案",
|
||||||
|
"localFileReloaded": "本機定價檔案已重新載入",
|
||||||
|
"localFileReloadFailed": "重新載入本機定價檔案失敗:{{error}}",
|
||||||
|
"syncNow": "立即同步"
|
||||||
|
},
|
||||||
"cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)",
|
"cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)",
|
||||||
"cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)"
|
"cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)"
|
||||||
},
|
},
|
||||||
@@ -2254,6 +2288,9 @@
|
|||||||
"skillDirNotFound": "技能目錄不存在:{{path}}",
|
"skillDirNotFound": "技能目錄不存在:{{path}}",
|
||||||
"directoryConflict": "技能目錄 '{{directory}}' 已被 {{existing_repo}} 佔用,無法從 {{new_repo}} 安裝",
|
"directoryConflict": "技能目錄 '{{directory}}' 已被 {{existing_repo}} 佔用,無法從 {{new_repo}} 安裝",
|
||||||
"emptyArchive": "下載的壓縮檔為空",
|
"emptyArchive": "下載的壓縮檔為空",
|
||||||
|
"invalidRepoRef": "倉庫位址不合法:{{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "壓縮檔解壓後超過 {{limit_mb}} MB 上限,已中止",
|
||||||
|
"archiveTooManyEntries": "壓縮檔項目過多({{count}}),上限 {{limit}}",
|
||||||
"downloadFailed": "下載失敗:HTTP {{status}}",
|
"downloadFailed": "下載失敗:HTTP {{status}}",
|
||||||
"allBranchesFailed": "所有分支下載失敗,嘗試了:{{branches}}",
|
"allBranchesFailed": "所有分支下載失敗,嘗試了:{{branches}}",
|
||||||
"httpError": "HTTP 錯誤 {{status}}",
|
"httpError": "HTTP 錯誤 {{status}}",
|
||||||
|
|||||||
@@ -1633,6 +1633,40 @@
|
|||||||
"modelsDevNoResults": "没有匹配的模型",
|
"modelsDevNoResults": "没有匹配的模型",
|
||||||
"modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
|
"modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
|
||||||
"modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
|
"modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
|
||||||
|
"modelsDevAutoSync": {
|
||||||
|
"title": "自动同步 models.dev 定价",
|
||||||
|
"description": "开启后,CC Switch 会在启动时定期拉取所选模型的最新价格,并保存到本地定价文件。",
|
||||||
|
"enabled": "已开启",
|
||||||
|
"disabled": "已关闭",
|
||||||
|
"enableConfirmTitle": "开启自动同步定价?",
|
||||||
|
"enableConfirmMessage": "开启后,CC Switch 会在启动时定期从 models.dev 更新所选模型的价格(最多每 6 小时一次)。同一模型 ID 的软件内置价格和手动设置价格都会被覆盖。",
|
||||||
|
"enableConfirmAction": "开启自动同步",
|
||||||
|
"configure": "选择模型",
|
||||||
|
"configureTitle": "选择自动同步定价的模型",
|
||||||
|
"configureDescription": "通过搜索和供应商筛选选择需要自动更新的模型,选择结果保存在本地。",
|
||||||
|
"commonModels": "自动包含常用模型",
|
||||||
|
"commonModelsDescription": "自动选择 {{count}} 个近期 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax 和 GLM 模型。",
|
||||||
|
"selectFiltered": "全选筛选结果({{count}})",
|
||||||
|
"clearFiltered": "清空筛选结果",
|
||||||
|
"selectedCount": "已选择 {{count}} 个模型",
|
||||||
|
"selectionHint": "归一化模型 ID 相同的选项只会导入一次。",
|
||||||
|
"commonBadge": "常用",
|
||||||
|
"selectionSaved": "自动定价同步选择已保存",
|
||||||
|
"syncSuccess": "定价同步完成:检查 {{imported}} 个模型,更新 {{changed}} 个",
|
||||||
|
"syncFailed": "定价同步失败:{{error}}",
|
||||||
|
"configLoadFailed": "加载本地定价配置失败:{{error}}",
|
||||||
|
"neverSynced": "从未同步",
|
||||||
|
"lastSync": "上次同步",
|
||||||
|
"commonStatus": "常用模型",
|
||||||
|
"lastError": "上次自动同步失败:{{error}}",
|
||||||
|
"localFile": "本地定价文件",
|
||||||
|
"openFolder": "打开目录",
|
||||||
|
"openFolderFailed": "打开本地定价文件目录失败:{{error}}",
|
||||||
|
"reloadLocalFile": "重新加载文件",
|
||||||
|
"localFileReloaded": "本地定价文件已重新加载",
|
||||||
|
"localFileReloadFailed": "重新加载本地定价文件失败:{{error}}",
|
||||||
|
"syncNow": "立即同步"
|
||||||
|
},
|
||||||
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
|
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
|
||||||
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
|
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
|
||||||
},
|
},
|
||||||
@@ -2283,6 +2317,9 @@
|
|||||||
"skillDirNotFound": "技能目录不存在:{{path}}",
|
"skillDirNotFound": "技能目录不存在:{{path}}",
|
||||||
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
|
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
|
||||||
"emptyArchive": "下载的压缩包为空",
|
"emptyArchive": "下载的压缩包为空",
|
||||||
|
"invalidRepoRef": "仓库地址不合法:{{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "压缩包解压后超过 {{limit_mb}} MB 上限,已中止",
|
||||||
|
"archiveTooManyEntries": "压缩包条目过多({{count}}),上限 {{limit}}",
|
||||||
"downloadFailed": "下载失败:HTTP {{status}}",
|
"downloadFailed": "下载失败:HTTP {{status}}",
|
||||||
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
||||||
"httpError": "HTTP 错误 {{status}}",
|
"httpError": "HTTP 错误 {{status}}",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
RequestLog,
|
RequestLog,
|
||||||
LogFilters,
|
LogFilters,
|
||||||
ModelPricing,
|
ModelPricing,
|
||||||
|
ModelsDevSyncConfig,
|
||||||
|
ModelsDevSyncState,
|
||||||
ProviderLimitStatus,
|
ProviderLimitStatus,
|
||||||
PaginatedLogs,
|
PaginatedLogs,
|
||||||
SessionSyncResult,
|
SessionSyncResult,
|
||||||
@@ -164,6 +166,27 @@ export const usageApi = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateModelPricingBatch: async (entries: ModelPricing[]): Promise<number> => {
|
||||||
|
return invoke("update_model_pricing_batch", { entries });
|
||||||
|
},
|
||||||
|
|
||||||
|
getModelsDevSyncConfig: async (): Promise<ModelsDevSyncState> => {
|
||||||
|
return invoke("get_models_dev_sync_config");
|
||||||
|
},
|
||||||
|
|
||||||
|
saveModelsDevSyncConfig: async (
|
||||||
|
config: ModelsDevSyncConfig,
|
||||||
|
): Promise<void> => {
|
||||||
|
return invoke("save_models_dev_sync_config", { config });
|
||||||
|
},
|
||||||
|
|
||||||
|
recordModelsDevSyncResult: async (
|
||||||
|
syncedAt: number | null,
|
||||||
|
error: string | null,
|
||||||
|
): Promise<void> => {
|
||||||
|
return invoke("record_models_dev_sync_result", { syncedAt, error });
|
||||||
|
},
|
||||||
|
|
||||||
deleteModelPricing: async (modelId: string): Promise<void> => {
|
deleteModelPricing: async (modelId: string): Promise<void> => {
|
||||||
return invoke("delete_model_pricing", { modelId });
|
return invoke("delete_model_pricing", { modelId });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function getErrorI18nKey(code: string): string {
|
|||||||
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
||||||
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
||||||
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
||||||
|
INVALID_REPO_REF: "skills.error.invalidRepoRef",
|
||||||
|
ARCHIVE_TOO_LARGE: "skills.error.archiveTooLarge",
|
||||||
|
ARCHIVE_TOO_MANY_ENTRIES: "skills.error.archiveTooManyEntries",
|
||||||
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
||||||
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
|
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { usageApi } from "@/lib/api/usage";
|
||||||
|
import {
|
||||||
|
fetchModelsDevPricing,
|
||||||
|
flattenModels,
|
||||||
|
resolveModelsDevSelection,
|
||||||
|
toModelPricing,
|
||||||
|
} from "@/lib/modelsDevPricing";
|
||||||
|
import type { ModelsDevSyncState } from "@/types/usage";
|
||||||
|
|
||||||
|
export interface ModelsDevSyncResult {
|
||||||
|
skipped: boolean;
|
||||||
|
selected: number;
|
||||||
|
imported: number;
|
||||||
|
changed: number;
|
||||||
|
syncedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODELS_DEV_SYNC_CONFIG_QUERY_KEY = [
|
||||||
|
"models-dev-sync-config",
|
||||||
|
] as const;
|
||||||
|
export const MODELS_DEV_STARTUP_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown) =>
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
export async function syncModelsDevPricing(
|
||||||
|
state?: ModelsDevSyncState,
|
||||||
|
force = false,
|
||||||
|
): Promise<ModelsDevSyncResult> {
|
||||||
|
const initialState = state ?? (await usageApi.getModelsDevSyncConfig());
|
||||||
|
const recentlySynced =
|
||||||
|
initialState.config.lastSyncAt !== null &&
|
||||||
|
Date.now() - initialState.config.lastSyncAt <
|
||||||
|
MODELS_DEV_STARTUP_SYNC_INTERVAL_MS;
|
||||||
|
if (!force && (!initialState.config.autoSyncEnabled || recentlySynced)) {
|
||||||
|
return {
|
||||||
|
skipped: true,
|
||||||
|
selected: 0,
|
||||||
|
imported: 0,
|
||||||
|
changed: 0,
|
||||||
|
syncedAt: initialState.config.lastSyncAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchModelsDevPricing();
|
||||||
|
const latestState = await usageApi.getModelsDevSyncConfig();
|
||||||
|
if (!force && !latestState.config.autoSyncEnabled) {
|
||||||
|
return {
|
||||||
|
skipped: true,
|
||||||
|
selected: 0,
|
||||||
|
imported: 0,
|
||||||
|
changed: 0,
|
||||||
|
syncedAt: latestState.config.lastSyncAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const selectedEntries = resolveModelsDevSelection(
|
||||||
|
flattenModels(data),
|
||||||
|
latestState.config,
|
||||||
|
);
|
||||||
|
const pricing = toModelPricing(selectedEntries);
|
||||||
|
const changed = pricing.length
|
||||||
|
? await usageApi.updateModelPricingBatch(pricing)
|
||||||
|
: 0;
|
||||||
|
const syncedAt = Date.now();
|
||||||
|
await usageApi.recordModelsDevSyncResult(syncedAt, null);
|
||||||
|
return {
|
||||||
|
skipped: false,
|
||||||
|
selected: selectedEntries.length,
|
||||||
|
imported: pricing.length,
|
||||||
|
changed,
|
||||||
|
syncedAt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await usageApi.recordModelsDevSyncResult(null, errorMessage(error));
|
||||||
|
} catch (saveError) {
|
||||||
|
console.warn(
|
||||||
|
"[models.dev] Failed to persist automatic sync error",
|
||||||
|
saveError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let startupSync: Promise<ModelsDevSyncResult> | null = null;
|
||||||
|
|
||||||
|
/** Run once per renderer and at most once per interval across WebView rebuilds. */
|
||||||
|
export function syncModelsDevPricingOnStartup(): Promise<ModelsDevSyncResult> {
|
||||||
|
startupSync ??= syncModelsDevPricing();
|
||||||
|
return startupSync;
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import type { ModelPricing, ModelsDevSyncConfig } from "@/types/usage";
|
||||||
|
|
||||||
|
export const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||||
|
const MODELS_DEV_FETCH_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
export interface ModelsDevCost {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
cache_read?: number;
|
||||||
|
cache_write?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsDevModalities {
|
||||||
|
input?: string[];
|
||||||
|
output?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsDevModel {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
release_date?: string;
|
||||||
|
cost?: ModelsDevCost;
|
||||||
|
modalities?: ModelsDevModalities;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsDevProvider {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
models?: Record<string, ModelsDevModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelsDevResponse = Record<string, ModelsDevProvider>;
|
||||||
|
|
||||||
|
export interface ModelsDevEntry {
|
||||||
|
key: string;
|
||||||
|
providerId: string;
|
||||||
|
providerName: string;
|
||||||
|
modelId: string;
|
||||||
|
normalizedId: string;
|
||||||
|
modelName: string;
|
||||||
|
releaseDate: string;
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NON_TEXT_MODEL_MARKERS = [
|
||||||
|
"audio",
|
||||||
|
"deprecated",
|
||||||
|
"embedding",
|
||||||
|
"image",
|
||||||
|
"moderation",
|
||||||
|
"realtime",
|
||||||
|
"transcribe",
|
||||||
|
"tts",
|
||||||
|
"video",
|
||||||
|
];
|
||||||
|
const NON_TEXT_OUTPUT_MODALITIES = new Set(["audio", "image", "video"]);
|
||||||
|
|
||||||
|
const isTextPricingModel = (modelId: string, model?: ModelsDevModel) => {
|
||||||
|
if (model?.status?.toLowerCase() === "deprecated") return false;
|
||||||
|
|
||||||
|
const outputModalities = model?.modalities?.output
|
||||||
|
?.filter((modality): modality is string => typeof modality === "string")
|
||||||
|
.map((modality) => modality.toLowerCase());
|
||||||
|
if (
|
||||||
|
outputModalities?.length &&
|
||||||
|
(!outputModalities.includes("text") ||
|
||||||
|
outputModalities.some((modality) =>
|
||||||
|
NON_TEXT_OUTPUT_MODALITIES.has(modality),
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchableName = `${modelId} ${model?.name ?? ""}`.toLowerCase();
|
||||||
|
return !NON_TEXT_MODEL_MARKERS.some((marker) =>
|
||||||
|
searchableName.includes(marker),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeModelIdForPricing(modelId: string): string {
|
||||||
|
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
|
||||||
|
const beforeColon = afterSlash.split(":")[0] ?? "";
|
||||||
|
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
|
||||||
|
if (normalized.endsWith("[1m]")) {
|
||||||
|
normalized = normalized.slice(0, -"[1m]".length).trim();
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrice(value: number): string {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||||
|
if (value >= 1e12) return "0";
|
||||||
|
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
||||||
|
return trimmed || "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
|
||||||
|
const entries: ModelsDevEntry[] = [];
|
||||||
|
for (const [providerId, provider] of Object.entries(data)) {
|
||||||
|
if (!provider || typeof provider !== "object") continue;
|
||||||
|
const providerName = provider.name || providerId;
|
||||||
|
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
||||||
|
if (!isTextPricingModel(modelId, model)) continue;
|
||||||
|
const cost = model?.cost;
|
||||||
|
const input = typeof cost?.input === "number" ? cost.input : null;
|
||||||
|
const output = typeof cost?.output === "number" ? cost.output : null;
|
||||||
|
if (input === null && output === null) continue;
|
||||||
|
const normalizedId = normalizeModelIdForPricing(modelId);
|
||||||
|
if (!normalizedId) continue;
|
||||||
|
entries.push({
|
||||||
|
key: `${providerId}/${modelId}`,
|
||||||
|
providerId,
|
||||||
|
providerName,
|
||||||
|
modelId,
|
||||||
|
normalizedId,
|
||||||
|
modelName: model?.name || modelId,
|
||||||
|
releaseDate:
|
||||||
|
typeof model?.release_date === "string" ? model.release_date : "",
|
||||||
|
input: input ?? 0,
|
||||||
|
output: output ?? 0,
|
||||||
|
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
|
||||||
|
cacheWrite:
|
||||||
|
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.releaseDate.localeCompare(a.releaseDate) ||
|
||||||
|
a.modelName.localeCompare(b.modelName),
|
||||||
|
);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchModelsDevPricing(): Promise<ModelsDevResponse> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
MODELS_DEV_FETCH_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await fetch(MODELS_DEV_API_URL, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as ModelsDevResponse;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_MODEL_LIMIT_PER_FAMILY = 6;
|
||||||
|
|
||||||
|
interface CommonFamilyRule {
|
||||||
|
id: string;
|
||||||
|
providers: ReadonlySet<string>;
|
||||||
|
matches: (modelId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_FAMILY_RULES: CommonFamilyRule[] = [
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
providers: new Set(["anthropic"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("claude-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt",
|
||||||
|
providers: new Set(["openai"]),
|
||||||
|
matches: (modelId) =>
|
||||||
|
modelId.startsWith("gpt-") ||
|
||||||
|
modelId.startsWith("o1-") ||
|
||||||
|
modelId.startsWith("o3-") ||
|
||||||
|
modelId.startsWith("o4-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini",
|
||||||
|
providers: new Set(["google"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("gemini-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grok",
|
||||||
|
providers: new Set(["xai"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("grok-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deepseek",
|
||||||
|
providers: new Set(["deepseek"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("deepseek-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "qwen",
|
||||||
|
providers: new Set(["alibaba"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("qwen"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mimo",
|
||||||
|
providers: new Set(["xiaomi"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("mimo-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "longcat",
|
||||||
|
providers: new Set(["longcat"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("longcat-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kimi",
|
||||||
|
providers: new Set(["moonshotai"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("kimi-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "minimax",
|
||||||
|
providers: new Set(["minimax-cn"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("minimax-m"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "glm",
|
||||||
|
providers: new Set(["zai"]),
|
||||||
|
matches: (modelId) => modelId.startsWith("glm-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Pick a bounded, canonical set of recent chat/coding models per family. */
|
||||||
|
export function getCommonModelKeys(entries: ModelsDevEntry[]): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const rule of COMMON_FAMILY_RULES) {
|
||||||
|
let count = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (
|
||||||
|
rule.providers.has(entry.providerId) &&
|
||||||
|
rule.matches(entry.modelId.toLowerCase())
|
||||||
|
) {
|
||||||
|
keys.add(entry.key);
|
||||||
|
count += 1;
|
||||||
|
if (count >= COMMON_MODEL_LIMIT_PER_FAMILY) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveModelsDevSelection(
|
||||||
|
entries: ModelsDevEntry[],
|
||||||
|
config: ModelsDevSyncConfig,
|
||||||
|
): ModelsDevEntry[] {
|
||||||
|
const explicit = new Set(config.selectedModelKeys);
|
||||||
|
const excluded = new Set(config.excludedCommonModelKeys);
|
||||||
|
const common = config.includeCommonModels
|
||||||
|
? getCommonModelKeys(entries)
|
||||||
|
: new Set<string>();
|
||||||
|
return entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
explicit.has(entry.key) ||
|
||||||
|
(common.has(entry.key) && !excluded.has(entry.key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toModelPricing(entries: ModelsDevEntry[]): ModelPricing[] {
|
||||||
|
const byModelId = new Map<string, ModelPricing>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (byModelId.has(entry.normalizedId)) continue;
|
||||||
|
byModelId.set(entry.normalizedId, {
|
||||||
|
modelId: entry.normalizedId,
|
||||||
|
displayName: entry.modelName,
|
||||||
|
inputCostPerMillion: formatPrice(entry.input),
|
||||||
|
outputCostPerMillion: formatPrice(entry.output),
|
||||||
|
cacheReadCostPerMillion: formatPrice(entry.cacheRead),
|
||||||
|
cacheCreationCostPerMillion: formatPrice(entry.cacheWrite),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(byModelId.values());
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
installGlobalErrorHandlers,
|
installGlobalErrorHandlers,
|
||||||
reportFrontendError,
|
reportFrontendError,
|
||||||
} from "./lib/frontendLogger";
|
} from "./lib/frontendLogger";
|
||||||
|
import {
|
||||||
|
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||||
|
syncModelsDevPricingOnStartup,
|
||||||
|
} from "./lib/modelsDevAutoSync";
|
||||||
|
|
||||||
installGlobalErrorHandlers();
|
installGlobalErrorHandlers();
|
||||||
|
|
||||||
@@ -124,6 +128,25 @@ async function bootstrap() {
|
|||||||
</FrontendErrorBoundary>
|
</FrontendErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void syncModelsDevPricingOnStartup()
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.skipped) {
|
||||||
|
return Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["usage"] }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// 离线或 models.dev 暂时不可用不应阻塞应用启动。
|
||||||
|
reportFrontendError("models_dev_startup_sync", error);
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ export interface ModelPricing {
|
|||||||
cacheCreationCostPerMillion: string;
|
cacheCreationCostPerMillion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelsDevSyncConfig {
|
||||||
|
autoSyncEnabled: boolean;
|
||||||
|
includeCommonModels: boolean;
|
||||||
|
selectedModelKeys: string[];
|
||||||
|
excludedCommonModelKeys: string[];
|
||||||
|
lastSyncAt: number | null;
|
||||||
|
lastSyncError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsDevSyncState {
|
||||||
|
config: ModelsDevSyncConfig;
|
||||||
|
configPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageSummary {
|
export interface UsageSummary {
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
totalCost: string;
|
totalCost: string;
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
getModelsDevSyncConfig,
|
||||||
|
saveModelsDevSyncConfig,
|
||||||
|
getModelPricing,
|
||||||
|
openAppConfigFolder,
|
||||||
|
syncModelsDevPricing,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
getModelsDevSyncConfig: vi.fn(),
|
||||||
|
saveModelsDevSyncConfig: vi.fn(),
|
||||||
|
getModelPricing: vi.fn(),
|
||||||
|
openAppConfigFolder: vi.fn(),
|
||||||
|
syncModelsDevPricing: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: { count?: number }) =>
|
||||||
|
options?.count == null ? key : `${key}:${options.count}`,
|
||||||
|
i18n: { resolvedLanguage: "en" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/usage", () => ({
|
||||||
|
usageApi: {
|
||||||
|
getModelsDevSyncConfig,
|
||||||
|
saveModelsDevSyncConfig,
|
||||||
|
getModelPricing,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/settings", () => ({
|
||||||
|
settingsApi: { openAppConfigFolder },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/modelsDevAutoSync", () => ({
|
||||||
|
MODELS_DEV_SYNC_CONFIG_QUERY_KEY: ["models-dev-sync-config"],
|
||||||
|
syncModelsDevPricing,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ModelsDevAutoSyncPanel } from "@/components/usage/ModelsDevAutoSyncPanel";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
||||||
|
config: {
|
||||||
|
autoSyncEnabled: false,
|
||||||
|
includeCommonModels: true,
|
||||||
|
selectedModelKeys: [],
|
||||||
|
excludedCommonModelKeys: [],
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<ModelsDevAutoSyncPanel />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ModelsDevAutoSyncPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getModelsDevSyncConfig.mockResolvedValue(state);
|
||||||
|
saveModelsDevSyncConfig.mockResolvedValue(undefined);
|
||||||
|
getModelPricing.mockResolvedValue([]);
|
||||||
|
openAppConfigFolder.mockResolvedValue(undefined);
|
||||||
|
syncModelsDevPricing.mockResolvedValue({
|
||||||
|
skipped: false,
|
||||||
|
selected: 2,
|
||||||
|
imported: 2,
|
||||||
|
changed: 1,
|
||||||
|
syncedAt: Date.now(),
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
models: {
|
||||||
|
"gpt-5": {
|
||||||
|
name: "GPT-5",
|
||||||
|
release_date: "2025-08-01",
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
name: "DeepSeek",
|
||||||
|
models: {
|
||||||
|
"deepseek-chat": {
|
||||||
|
name: "DeepSeek Chat",
|
||||||
|
release_date: "2025-12-01",
|
||||||
|
cost: { input: 0.3, output: 1.2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads automatic sync as disabled by default", async () => {
|
||||||
|
renderPanel();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("usage.modelsDevAutoSync.title"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(state.configPath)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("switch")).not.toBeChecked();
|
||||||
|
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists disabling without showing the overwrite warning", async () => {
|
||||||
|
const enabledState = {
|
||||||
|
...state,
|
||||||
|
config: { ...state.config, autoSyncEnabled: true },
|
||||||
|
};
|
||||||
|
getModelsDevSyncConfig.mockResolvedValue(enabledState);
|
||||||
|
renderPanel();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("switch"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
||||||
|
...enabledState.config,
|
||||||
|
autoSyncEnabled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns about price overwrites before enabling automatic sync", async () => {
|
||||||
|
renderPanel();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("switch"));
|
||||||
|
|
||||||
|
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "usage.modelsDevAutoSync.enableConfirmAction",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
||||||
|
...state.config,
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps automatic sync disabled when the overwrite warning is cancelled", async () => {
|
||||||
|
renderPanel();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("switch"));
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "common.cancel" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("switch")).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads the automatic sync config after reading the local pricing file", async () => {
|
||||||
|
const initialState = {
|
||||||
|
...state,
|
||||||
|
config: { ...state.config, autoSyncEnabled: true },
|
||||||
|
};
|
||||||
|
getModelsDevSyncConfig
|
||||||
|
.mockResolvedValueOnce(initialState)
|
||||||
|
.mockResolvedValue(state);
|
||||||
|
renderPanel();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: "usage.modelsDevAutoSync.reloadLocalFile",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getModelsDevSyncConfig).toHaveBeenCalledTimes(2),
|
||||||
|
);
|
||||||
|
expect(getModelPricing).toHaveBeenCalledTimes(1);
|
||||||
|
await waitFor(() => expect(screen.getByRole("switch")).not.toBeChecked());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the searchable multi-select dialog with common models selected", async () => {
|
||||||
|
renderPanel();
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: "usage.modelsDevAutoSync.configure",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("usage.modelsDevAutoSync.configureTitle"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("GPT-5")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("DeepSeek Chat")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("usage.modelsDevAutoSync.selectedCount:2"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("usage.modelsDevAutoSync.commonBadge"),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
formatPrice,
|
formatPrice,
|
||||||
normalizeModelIdForPricing,
|
normalizeModelIdForPricing,
|
||||||
} from "@/components/usage/ModelsDevPickerDialog";
|
} from "@/components/usage/ModelsDevPickerDialog";
|
||||||
|
import {
|
||||||
|
getCommonModelKeys,
|
||||||
|
resolveModelsDevSelection,
|
||||||
|
toModelPricing,
|
||||||
|
} from "@/lib/modelsDevPricing";
|
||||||
|
|
||||||
describe("normalizeModelIdForPricing", () => {
|
describe("normalizeModelIdForPricing", () => {
|
||||||
it("keeps already-normalized ids unchanged", () => {
|
it("keeps already-normalized ids unchanged", () => {
|
||||||
@@ -141,4 +146,183 @@ describe("flattenModels", () => {
|
|||||||
// 完全没有定价的模型被过滤
|
// 完全没有定价的模型被过滤
|
||||||
expect(entries.some((e) => e.modelId === "free-model")).toBe(false);
|
expect(entries.some((e) => e.modelId === "free-model")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters deprecated and non-text output models while keeping multimodal input models", () => {
|
||||||
|
const entries = flattenModels({
|
||||||
|
acme: {
|
||||||
|
models: {
|
||||||
|
"multimodal-chat": {
|
||||||
|
name: "Multimodal Chat",
|
||||||
|
modalities: {
|
||||||
|
input: ["text", "image", "audio", "video"],
|
||||||
|
output: ["text"],
|
||||||
|
},
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
"legacy-chat": {
|
||||||
|
status: "deprecated",
|
||||||
|
modalities: { output: ["text"] },
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
"speech-model": {
|
||||||
|
modalities: { output: ["audio"] },
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
"mixed-output-model": {
|
||||||
|
modalities: { output: ["text", "audio"] },
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
"movie-generator": {
|
||||||
|
modalities: { output: ["video"] },
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
"fallback-video-model": {
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.map((entry) => entry.modelId)).toEqual(["multimodal-chat"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a bounded canonical set of common model families", () => {
|
||||||
|
const openAiModels = Object.fromEntries(
|
||||||
|
Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const version = index + 1;
|
||||||
|
return [
|
||||||
|
`gpt-${version}`,
|
||||||
|
{
|
||||||
|
name: `GPT ${version}`,
|
||||||
|
release_date: `2025-0${version}-01`,
|
||||||
|
cost: { input: version, output: version * 2 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const entries = flattenModels({
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
models: {
|
||||||
|
...openAiModels,
|
||||||
|
"gpt-image-1": {
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aggregator: {
|
||||||
|
name: "Aggregator",
|
||||||
|
models: {
|
||||||
|
"gpt-7": {
|
||||||
|
release_date: "2026-02-01",
|
||||||
|
cost: { input: 9, output: 18 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
name: "Anthropic",
|
||||||
|
models: {
|
||||||
|
"claude-sonnet-5": {
|
||||||
|
release_date: "2026-06-01",
|
||||||
|
cost: { input: 3, output: 15 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
name: "DeepSeek",
|
||||||
|
models: {
|
||||||
|
"deepseek-chat": {
|
||||||
|
release_date: "2025-12-01",
|
||||||
|
cost: { input: 0.3, output: 1.2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xiaomi: {
|
||||||
|
name: "Xiaomi",
|
||||||
|
models: {
|
||||||
|
"mimo-v2.5": {
|
||||||
|
release_date: "2026-04-01",
|
||||||
|
cost: { input: 0.2, output: 1 },
|
||||||
|
},
|
||||||
|
"mimo-v2.5-tts": {
|
||||||
|
release_date: "2026-05-01",
|
||||||
|
cost: { input: 0.1, output: 0.5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
longcat: {
|
||||||
|
name: "LongCat",
|
||||||
|
models: {
|
||||||
|
"LongCat-2.0": {
|
||||||
|
release_date: "2026-03-01",
|
||||||
|
cost: { input: 0.4, output: 1.6 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const common = getCommonModelKeys(entries);
|
||||||
|
expect(common.has("openai/gpt-image-1")).toBe(false);
|
||||||
|
expect(common.has("aggregator/gpt-7")).toBe(false);
|
||||||
|
expect(common.has("openai/gpt-7")).toBe(true);
|
||||||
|
expect(common.has("openai/gpt-1")).toBe(false);
|
||||||
|
expect(common.has("anthropic/claude-sonnet-5")).toBe(true);
|
||||||
|
expect(common.has("deepseek/deepseek-chat")).toBe(true);
|
||||||
|
expect(common.has("xiaomi/mimo-v2.5")).toBe(true);
|
||||||
|
expect(common.has("xiaomi/mimo-v2.5-tts")).toBe(false);
|
||||||
|
expect(common.has("longcat/LongCat-2.0")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines common and explicit selections and deduplicates normalized ids", () => {
|
||||||
|
const entries = flattenModels({
|
||||||
|
openai: {
|
||||||
|
models: {
|
||||||
|
"gpt-5": {
|
||||||
|
name: "GPT-5 Official",
|
||||||
|
release_date: "2025-08-01",
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
relay: {
|
||||||
|
models: {
|
||||||
|
"vendor/GPT-5": {
|
||||||
|
name: "GPT-5 Relay",
|
||||||
|
release_date: "2025-07-01",
|
||||||
|
cost: { input: 9, output: 18 },
|
||||||
|
},
|
||||||
|
"custom-model": {
|
||||||
|
name: "Custom",
|
||||||
|
release_date: "2025-06-01",
|
||||||
|
cost: { input: 0.5, output: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selected = resolveModelsDevSelection(entries, {
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
includeCommonModels: true,
|
||||||
|
selectedModelKeys: ["relay/vendor/GPT-5", "relay/custom-model"],
|
||||||
|
excludedCommonModelKeys: ["openai/gpt-5"],
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected.map((entry) => entry.key)).toEqual([
|
||||||
|
"relay/vendor/GPT-5",
|
||||||
|
"relay/custom-model",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pricing = toModelPricing([
|
||||||
|
entries.find((entry) => entry.key === "openai/gpt-5")!,
|
||||||
|
entries.find((entry) => entry.key === "relay/vendor/GPT-5")!,
|
||||||
|
]);
|
||||||
|
expect(pricing).toHaveLength(1);
|
||||||
|
expect(pricing[0]).toMatchObject({
|
||||||
|
modelId: "gpt-5",
|
||||||
|
displayName: "GPT-5 Official",
|
||||||
|
inputCostPerMillion: "1",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
getModelsDevSyncConfig,
|
||||||
|
updateModelPricingBatch,
|
||||||
|
recordModelsDevSyncResult,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
getModelsDevSyncConfig: vi.fn(),
|
||||||
|
updateModelPricingBatch: vi.fn(),
|
||||||
|
recordModelsDevSyncResult: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/usage", () => ({
|
||||||
|
usageApi: {
|
||||||
|
getModelsDevSyncConfig,
|
||||||
|
updateModelPricingBatch,
|
||||||
|
recordModelsDevSyncResult,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
MODELS_DEV_STARTUP_SYNC_INTERVAL_MS,
|
||||||
|
syncModelsDevPricing,
|
||||||
|
} from "@/lib/modelsDevAutoSync";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
||||||
|
config: {
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
includeCommonModels: true,
|
||||||
|
selectedModelKeys: ["relay/custom-model"],
|
||||||
|
excludedCommonModelKeys: [],
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("syncModelsDevPricing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
updateModelPricingBatch.mockResolvedValue(2);
|
||||||
|
recordModelsDevSyncResult.mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
openai: {
|
||||||
|
models: {
|
||||||
|
"gpt-5": {
|
||||||
|
name: "GPT-5",
|
||||||
|
release_date: "2025-08-01",
|
||||||
|
cost: { input: 1, output: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
relay: {
|
||||||
|
models: {
|
||||||
|
"custom-model": {
|
||||||
|
name: "Custom Model",
|
||||||
|
release_date: "2025-07-01",
|
||||||
|
cost: { input: 0.5, output: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips network access when automatic sync is disabled", async () => {
|
||||||
|
getModelsDevSyncConfig.mockResolvedValue({
|
||||||
|
...state,
|
||||||
|
config: { ...state.config, autoSyncEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await syncModelsDevPricing();
|
||||||
|
|
||||||
|
expect(result.skipped).toBe(true);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips startup network access when pricing synced within the interval", async () => {
|
||||||
|
const lastSyncAt = Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 1;
|
||||||
|
getModelsDevSyncConfig.mockResolvedValue({
|
||||||
|
...state,
|
||||||
|
config: { ...state.config, lastSyncAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await syncModelsDevPricing();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
skipped: true,
|
||||||
|
selected: 0,
|
||||||
|
imported: 0,
|
||||||
|
changed: 0,
|
||||||
|
syncedAt: lastSyncAt,
|
||||||
|
});
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports common and explicitly selected models in one batch", async () => {
|
||||||
|
getModelsDevSyncConfig.mockResolvedValue(state);
|
||||||
|
|
||||||
|
const result = await syncModelsDevPricing();
|
||||||
|
|
||||||
|
expect(updateModelPricingBatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ modelId: "gpt-5", inputCostPerMillion: "1" }),
|
||||||
|
expect.objectContaining({
|
||||||
|
modelId: "custom-model",
|
||||||
|
inputCostPerMillion: "0.5",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
skipped: false,
|
||||||
|
selected: 2,
|
||||||
|
imported: 2,
|
||||||
|
changed: 2,
|
||||||
|
});
|
||||||
|
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(
|
||||||
|
expect.any(Number),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const fetchOptions = vi.mocked(fetch).mock.calls[0]?.[1];
|
||||||
|
expect(fetchOptions).toEqual({ signal: expect.any(AbortSignal) });
|
||||||
|
expect(fetchOptions).not.toHaveProperty("cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops a startup sync when automatic sync is disabled during download", async () => {
|
||||||
|
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
||||||
|
...state,
|
||||||
|
config: { ...state.config, autoSyncEnabled: false, lastSyncAt: 123 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await syncModelsDevPricing();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
skipped: true,
|
||||||
|
selected: 0,
|
||||||
|
imported: 0,
|
||||||
|
changed: 0,
|
||||||
|
syncedAt: 123,
|
||||||
|
});
|
||||||
|
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||||
|
expect(recordModelsDevSyncResult).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the latest model selection after the download completes", async () => {
|
||||||
|
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
||||||
|
...state,
|
||||||
|
config: {
|
||||||
|
...state.config,
|
||||||
|
includeCommonModels: false,
|
||||||
|
selectedModelKeys: ["relay/custom-model"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncModelsDevPricing();
|
||||||
|
|
||||||
|
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ modelId: "custom-model" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the latest selection for a forced sync even when automatic sync is disabled", async () => {
|
||||||
|
getModelsDevSyncConfig.mockResolvedValueOnce({
|
||||||
|
...state,
|
||||||
|
config: {
|
||||||
|
...state.config,
|
||||||
|
autoSyncEnabled: false,
|
||||||
|
includeCommonModels: false,
|
||||||
|
selectedModelKeys: ["relay/custom-model"],
|
||||||
|
lastSyncAt: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await syncModelsDevPricing(state, true);
|
||||||
|
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ modelId: "custom-model" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the last error without replacing the previous success time", async () => {
|
||||||
|
const previous = { ...state, config: { ...state.config, lastSyncAt: 123 } };
|
||||||
|
getModelsDevSyncConfig.mockResolvedValue(previous);
|
||||||
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
|
||||||
|
|
||||||
|
await expect(syncModelsDevPricing()).rejects.toThrow("offline");
|
||||||
|
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(null, "offline");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user