Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 5194624d27 chore(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 15:09:36 +00:00
40 changed files with 414 additions and 5668 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
@@ -62,7 +62,7 @@ jobs:
os: [ubuntu-22.04, windows-latest, macos-latest] os: [ubuntu-22.04, windows-latest, macos-latest]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 1 fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
- name: Checkout scripts - name: Checkout scripts
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true' if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
sparse-checkout: scripts sparse-checkout: scripts
+5 -61
View File
@@ -1908,53 +1908,25 @@ 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 if let Some(model_providers) = doc["model_providers"].as_table_mut() {
.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.insert(&provider_key, toml_edit::table()); model_providers[&provider_key] = toml_edit::table();
} }
if let Some(provider_table) = model_providers if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
.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.insert(field, toml_edit::value(trimmed)); provider_table[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
@@ -2613,34 +2585,6 @@ 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"
-4
View File
@@ -301,10 +301,6 @@ 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)
+84 -45
View File
@@ -1,9 +1,10 @@
//! 使用统计相关命令 //! 使用统计相关命令
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;
/// 获取使用量汇总 /// 获取使用量汇总
@@ -124,7 +125,6 @@ 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,53 +181,72 @@ 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> {
crate::services::model_pricing::update_model_pricing( let db = state.db.clone();
&state.db, let model_id = model_id.trim().to_string();
ModelPricingInfo { let display_name = 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",
));
}
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_per_million: input_cost, input_cost.trim(),
output_cost_per_million: output_cost, output_cost.trim(),
cache_read_cost_per_million: cache_read_cost, cache_read_cost.trim(),
cache_creation_cost_per_million: cache_creation_cost, cache_creation_cost.trim()
}, ],
)?; )
.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(
@@ -241,7 +260,15 @@ 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> {
crate::services::model_pricing::delete_model_pricing(&state.db, &model_id)?; let db = state.db.clone();
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(())
} }
@@ -299,6 +326,18 @@ 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::*;
-3
View File
@@ -144,9 +144,6 @@ 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) {
+3 -134
View File
@@ -832,34 +832,9 @@ fn merge_grokbuild_config(
.as_ref() .as_ref()
.is_none_or(|value| value.is_empty()) .is_none_or(|value| value.is_empty())
{ {
// Only inline an explicitly-declared `api_key`. Do NOT resolve `env_key` request.api_key = model.api_key.or_else(|| {
// (or any process env var) into a plaintext value here: a deeplink is crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
// 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
@@ -954,112 +929,6 @@ 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());
+1 -14
View File
@@ -33,25 +33,12 @@ 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, branch: request.branch.unwrap_or_else(|| "main".to_string()),
enabled: request.enabled.unwrap_or(true), enabled: request.enabled.unwrap_or(true),
}; };
+2 -64
View File
@@ -152,71 +152,8 @@ 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();
// 确保目录存在 // 确保目录存在
@@ -235,7 +172,8 @@ pub fn write_gemini_env_text_atomic(content: &str) -> Result<(), AppError> {
} }
} }
write_text_file(&path, content)?; let content = serialize_env_file(map);
write_text_file(&path, &content)?;
// 设置文件权限为 600(仅所有者可读写) // 设置文件权限为 600(仅所有者可读写)
#[cfg(unix)] #[cfg(unix)]
+9 -52
View File
@@ -209,22 +209,21 @@ 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)?;
// Credentials only come from two explicit, config-declared sources: let api_key = config
// 1. an inline `api_key`, or .api_key
// 2. the process env var named by `env_key`. .or_else(|| {
//
// 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))
} }
@@ -541,48 +540,6 @@ 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!({
-15
View File
@@ -1184,17 +1184,6 @@ 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 表中的代理状态,自动恢复代理服务
@@ -1532,11 +1521,7 @@ 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,
+22 -153
View File
@@ -348,74 +348,6 @@ 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,
@@ -424,6 +356,7 @@ 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();
@@ -450,9 +383,16 @@ 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();
@@ -485,7 +425,19 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
} }
}; };
remove_mcp_server_from_doc(&mut doc, id); // 从正确的位置删除:[mcp_servers]
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();
@@ -731,89 +683,6 @@ 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.tomlmcp_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!({
+6 -36
View File
@@ -148,30 +148,10 @@ pub fn sync_single_server_to_grokbuild(
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}")) AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
})? })?
}; };
// 若 mcp_servers 缺失或存在但不是 table(如 `mcp_servers = "x"` / `[]`), if !doc.contains_key("mcp_servers") {
// 用空 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();
} }
let servers = doc doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
.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())
} }
@@ -191,22 +171,12 @@ pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
return Ok(()); return Ok(());
} }
}; };
// 与写入侧对称使用 as_table_like_mutinline table 形态下 as_table_mut 返回 if let Some(servers) = doc
// None,删除会静默失效——界面显示已移除,Grok Build 下次启动仍会加载它。 .get_mut("mcp_servers")
if let Some(item) = doc.get_mut("mcp_servers") { .and_then(toml_edit::Item::as_table_mut)
// `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())
} }
+4 -106
View File
@@ -95,27 +95,12 @@ 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))?;
let value: Value = json5::from_str(&content).map_err(|e| { 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 自动升级成对象,
// 数组或标量会直接 panicpanic 发生在 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> {
@@ -138,13 +123,7 @@ 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()?;
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到, if full_config.get("provider").is_none() {
// 写入会静默失效——界面显示添加成功而文件里没有。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!({});
} }
@@ -163,8 +142,6 @@ 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)
@@ -205,10 +182,7 @@ 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_some_and(Value::is_object) { if full_config.get("mcp").is_none() {
if full_config.get("mcp").is_some() {
log::warn!("opencode.json 的 mcp 不是对象,已重置为空对象");
}
full_config["mcp"] = json!({}); full_config["mcp"] = json!({});
} }
@@ -224,8 +198,6 @@ 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)
@@ -293,77 +265,3 @@ 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"
);
}
}
+4 -16
View File
@@ -169,23 +169,11 @@ 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)
} }
// GrokBuild 的 base_url 与 api_key 必须各自解析:extract_credentials 在 AppType::GrokBuild => settings
// 凭据缺失时整个 Option 变 None,一并 unwrap_or_default 会把明明写在 .get("config")
// 配置里的 base_url 也清成空串。凭据缺失是常态(env_key 指向的变量在 .and_then(Value::as_str)
// 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)
.map(|(_, api_key)| api_key) .unwrap_or_default(),
.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,21 +589,6 @@ 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
@@ -834,15 +819,6 @@ 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,39 +1418,13 @@ 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" => {
// Only accept an object message; a malformed upstream could send a if let Some(msg) = value.get("message") {
// 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()) {
// Sanitize to an object: any later index-assignment (`["text"]`, let block = value.get("content_block").cloned().unwrap_or(json!({}));
// `["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();
} }
@@ -2979,42 +2953,4 @@ 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());
}
} }
-1
View File
@@ -6,7 +6,6 @@ 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;
-761
View File
@@ -1,761 +0,0 @@
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"));
});
}
}
+5 -708
View File
@@ -702,469 +702,6 @@ 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
@@ -3492,39 +3029,20 @@ 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 /
/// 私钥命名。 /// 私钥命名。
pub(crate) fn is_sensitive_config_key(name: &str) -> bool { 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",
@@ -3724,13 +3242,7 @@ 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 {
@@ -3751,221 +3263,6 @@ 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
File diff suppressed because it is too large Load Diff
@@ -5,77 +5,11 @@ 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;
@@ -156,7 +90,9 @@ export function useGeminiCommonConfig({
} }
const keys = Object.keys(parsed); const keys = Object.keys(parsed);
const forbiddenKeys = keys.filter(isForbiddenCommonEnvKey); const forbiddenKeys = keys.filter((key) =>
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
);
if (forbiddenKeys.length > 0) { if (forbiddenKeys.length > 0) {
return { return {
env: {}, env: {},
@@ -1,668 +0,0 @@
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)}
/>
</>
);
}
+109 -13
View File
@@ -22,24 +22,114 @@ 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";
export { const MODELS_DEV_API_URL = "https://models.dev/api.json";
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_pricingusage_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;
@@ -70,7 +160,13 @@ export function ModelsDevPickerDialog({
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ["models-dev-pricing"], queryKey: ["models-dev-pricing"],
queryFn: fetchModelsDevPricing, queryFn: async (): Promise<ModelsDevResponse> => {
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,7 +32,6 @@ 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];
@@ -342,8 +341,6 @@ 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")}
-37
View File
@@ -1633,40 +1633,6 @@
"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)"
}, },
@@ -2317,9 +2283,6 @@
"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}}",
-37
View File
@@ -1633,40 +1633,6 @@
"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)"
}, },
@@ -2317,9 +2283,6 @@
"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}}",
-37
View File
@@ -1604,40 +1604,6 @@
"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)"
}, },
@@ -2288,9 +2254,6 @@
"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}}",
-37
View File
@@ -1633,40 +1633,6 @@
"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)"
}, },
@@ -2317,9 +2283,6 @@
"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}}",
-23
View File
@@ -8,8 +8,6 @@ import type {
RequestLog, RequestLog,
LogFilters, LogFilters,
ModelPricing, ModelPricing,
ModelsDevSyncConfig,
ModelsDevSyncState,
ProviderLimitStatus, ProviderLimitStatus,
PaginatedLogs, PaginatedLogs,
SessionSyncResult, SessionSyncResult,
@@ -166,27 +164,6 @@ 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 });
}, },
-3
View File
@@ -37,9 +37,6 @@ 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",
}; };
-93
View File
@@ -1,93 +0,0 @@
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;
}
-277
View File
@@ -1,277 +0,0 @@
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());
}
-23
View File
@@ -19,10 +19,6 @@ import {
installGlobalErrorHandlers, installGlobalErrorHandlers,
reportFrontendError, reportFrontendError,
} from "./lib/frontendLogger"; } from "./lib/frontendLogger";
import {
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
syncModelsDevPricingOnStartup,
} from "./lib/modelsDevAutoSync";
installGlobalErrorHandlers(); installGlobalErrorHandlers();
@@ -128,25 +124,6 @@ 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();
-14
View File
@@ -67,20 +67,6 @@ 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;
@@ -1,226 +0,0 @@
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,11 +5,6 @@ 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", () => {
@@ -146,183 +141,4 @@ 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",
});
});
}); });
-196
View File
@@ -1,196 +0,0 @@
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");
});
});