mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
Replace the passthrough/splice YAML layer with a single omp_config.rs: #6747's verified agent-dir resolution (profiles, PI_CONFIG_DIR, XDG data dir) plus #8308's block-level editor that rewrites only the one provider entry, refuses anchors/tags/flow style instead of re-emitting them, and rolls the file back byte for byte when the database commit fails. - Validate only the node being written; model-level `api` also accepts omp's runner APIs. - Drop ProviderMeta.ompPassthroughYaml, validation.rs and omp_state; the current-state command lives in commands/omp.rs and reuses PiCurrentState. - Usage scripts go through the Pi command with an `app` argument. - MCP reuses Pi's transport helpers; mcp.json writes are revision checked. - Skills follow Pi: no DB column, state derived from <agent>/skills, and uninstall keeps an unverifiable same-name directory in either app. - Move enabled_omp to a new v22 -> v23 migration (v22 shipped in 4.0.19) and add it to mcp_servers only; revert the .gitignore additions. Co-authored-by: allenxu09 <s23372.xu@stu.scie.com.cn>
4190 lines
159 KiB
Rust
4190 lines
159 KiB
Rust
//! Schema 定义和迁移
|
||
//!
|
||
//! 负责数据库表结构的创建和版本迁移。
|
||
|
||
use super::{lock_conn, Database, SCHEMA_VERSION};
|
||
use crate::error::AppError;
|
||
use rusqlite::{params, Connection};
|
||
use serde::Serialize;
|
||
|
||
#[derive(Serialize)]
|
||
struct LegacySkillMigrationRow {
|
||
directory: String,
|
||
app_type: String,
|
||
}
|
||
|
||
impl Database {
|
||
/// 创建所有数据库表
|
||
pub(crate) fn create_tables(&self) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
Self::create_tables_on_conn(&conn)
|
||
}
|
||
|
||
/// 在指定连接上创建表(供迁移和测试使用)
|
||
pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||
// 1. Providers 表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS providers (
|
||
id TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
settings_config TEXT NOT NULL,
|
||
website_url TEXT,
|
||
category TEXT,
|
||
created_at INTEGER,
|
||
sort_index INTEGER,
|
||
notes TEXT,
|
||
icon TEXT,
|
||
icon_color TEXT,
|
||
meta TEXT NOT NULL DEFAULT '{}',
|
||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (id, app_type)
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 2. Provider Endpoints 表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS provider_endpoints (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
provider_id TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
url TEXT NOT NULL,
|
||
added_at INTEGER,
|
||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 3. MCP Servers 表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_mcode BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_agy BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_kimi BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_pi BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_omp BOOLEAN NOT NULL DEFAULT 0
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 4. Prompts 表
|
||
conn.execute("CREATE TABLE IF NOT EXISTS prompts (
|
||
id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
|
||
description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
|
||
PRIMARY KEY (id, app_type)
|
||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 5. Skills 表(v3.10.0+ 统一结构)
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS skills (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
directory TEXT NOT NULL,
|
||
repo_owner TEXT,
|
||
repo_name TEXT,
|
||
repo_branch TEXT DEFAULT 'main',
|
||
readme_url TEXT,
|
||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_mcode BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_agy BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_kimi BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||
content_hash TEXT,
|
||
updated_at INTEGER NOT NULL DEFAULT 0
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 6. Skill Repos 表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
||
owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
|
||
enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 7. Settings 表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 初始化三行数据(每应用不同默认值)
|
||
//
|
||
// 兼容旧数据库:
|
||
// - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert;
|
||
// - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。
|
||
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||
circuit_error_rate_threshold, circuit_min_requests)
|
||
VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||
circuit_error_rate_threshold, circuit_min_requests)
|
||
VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||
circuit_error_rate_threshold, circuit_min_requests)
|
||
VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||
circuit_error_rate_threshold, circuit_min_requests)
|
||
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
}
|
||
|
||
// 9. Provider Health 表
|
||
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
|
||
provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
|
||
consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
|
||
last_error TEXT, updated_at TEXT NOT NULL,
|
||
PRIMARY KEY (provider_id, app_type),
|
||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 10. Proxy Request Logs 表
|
||
// pricing_model = 写入时实际用于计价的模型名(pricing_model_source 解析结果),
|
||
// 回填按它重算;NULL 表示 v11 之前的历史行,'' 表示未计价的错误行。
|
||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||
request_model TEXT,
|
||
pricing_model TEXT,
|
||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||
input_token_semantics INTEGER NOT NULL DEFAULT 0,
|
||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL,
|
||
data_source TEXT NOT NULL DEFAULT 'proxy'
|
||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Self::create_request_logs_usage_indexes_if_supported(conn)?;
|
||
|
||
// 11. Model Pricing 表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 12. Stream Check Logs 表
|
||
conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
|
||
app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
|
||
response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
|
||
retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
|
||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
|
||
ON stream_check_logs(app_type, provider_id, tested_at DESC)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 注意:circuit_breaker_config 已合并到 proxy_config 表中
|
||
|
||
// 16. Proxy Live Backup 表 (Live 配置备份)
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
|
||
app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 17. Usage Daily Rollups 表 (日聚合统计)
|
||
// request_model 保留路由接管的「客户端别名 → 真实模型」映射维度,
|
||
// pricing_model 保留写入时的计价基准(request 计价模式下与 model 分叉),
|
||
// 否则明细被 prune 后接管计费不可审计;历史行迁移时填 ''(未知)。
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||
date TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
provider_id TEXT NOT NULL,
|
||
model TEXT NOT NULL,
|
||
request_model TEXT NOT NULL DEFAULT '',
|
||
pricing_model TEXT NOT NULL DEFAULT '',
|
||
request_count INTEGER NOT NULL DEFAULT 0,
|
||
success_count INTEGER NOT NULL DEFAULT 0,
|
||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||
input_token_semantics INTEGER NOT NULL DEFAULT 0,
|
||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 18. Session Log Sync 表 (会话日志同步状态)
|
||
//
|
||
// last_byte_offset:Claude 路径的字节游标(seek 增量读);NULL 表示
|
||
// 尚无字节游标(旧行号游标或非 Claude 路径行),此时回退全量读。
|
||
// last_tail_fingerprint:游标边界前尾部字节的指纹,用于识别文件被
|
||
// 外部重写(同尺寸/更大的替换无法靠 size 检测);NULL 表示无指纹
|
||
// 可校验,按纯追加处理。
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS session_log_sync (
|
||
file_path TEXT PRIMARY KEY,
|
||
last_modified INTEGER NOT NULL,
|
||
last_line_offset INTEGER NOT NULL DEFAULT 0,
|
||
last_synced_at INTEGER NOT NULL,
|
||
last_byte_offset INTEGER,
|
||
last_tail_fingerprint INTEGER
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// Session detail rows are pruned after rollup, so request IDs needed
|
||
// for fork/rewrite deduplication live in a compact durable ledger.
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS session_usage_dedup (
|
||
data_source TEXT NOT NULL,
|
||
request_id TEXT NOT NULL,
|
||
semantic_id TEXT NOT NULL,
|
||
has_entry_id INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (data_source, request_id)
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_session_usage_dedup_semantic
|
||
ON session_usage_dedup(data_source, semantic_id, has_entry_id)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 19. Profiles 表(全应用共享的项目实体,payload 按 app 分槽快照
|
||
// 供应商/MCP/Skills/Prompt;各应用分组的 current 标记在 settings 表)
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS profiles (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
payload TEXT NOT NULL,
|
||
sort_order INTEGER,
|
||
created_at INTEGER,
|
||
updated_at INTEGER
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 修复跑过未发布开发版的库:current 标记曾是全局 key,现按应用分组
|
||
// (随 v12 定稿为 current_profile_id_<scope>,不单独 bump 版本)
|
||
if conn
|
||
.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value)
|
||
SELECT 'current_profile_id_claude', value FROM settings
|
||
WHERE key = 'current_profile_id'",
|
||
[],
|
||
)
|
||
.is_ok()
|
||
{
|
||
let _ = conn.execute("DELETE FROM settings WHERE key = 'current_profile_id'", []);
|
||
}
|
||
|
||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||
[],
|
||
);
|
||
|
||
// 尝试添加基础配置列到 proxy_config 表(兼容 v3.9.0-2 升级)
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
|
||
[],
|
||
);
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||
[],
|
||
);
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
|
||
[],
|
||
);
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
|
||
[],
|
||
);
|
||
|
||
// 尝试添加超时配置列到 proxy_config 表
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
|
||
[],
|
||
);
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
|
||
[],
|
||
);
|
||
let _ = conn.execute(
|
||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
|
||
[],
|
||
);
|
||
|
||
// 兼容:若旧版 proxy_config 仍为单例结构(无 app_type),则在启动时直接转换为三行结构
|
||
// 说明:user_version=2 时不会再触发 v1->v2 迁移,但新代码查询依赖 app_type 列。
|
||
if Self::table_exists(conn, "proxy_config")?
|
||
&& !Self::has_column(conn, "proxy_config", "app_type")?
|
||
{
|
||
Self::migrate_proxy_config_to_per_app(conn)?;
|
||
}
|
||
|
||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"providers",
|
||
"in_failover_queue",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
// 删除旧的 failover_queue 表(如果存在)
|
||
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||
|
||
// 为故障转移队列创建索引(基于 providers 表)
|
||
let _ = conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||
ON providers(app_type, in_failover_queue, sort_index)",
|
||
[],
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 应用 Schema 迁移
|
||
pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
Self::apply_schema_migrations_on_conn(&conn)
|
||
}
|
||
|
||
/// 在指定连接上应用 Schema 迁移
|
||
pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||
conn.execute("SAVEPOINT schema_migration;", [])
|
||
.map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
|
||
|
||
let mut version = Self::get_user_version(conn)?;
|
||
|
||
if version > SCHEMA_VERSION {
|
||
conn.execute("ROLLBACK TO schema_migration;", []).ok();
|
||
conn.execute("RELEASE schema_migration;", []).ok();
|
||
return Err(AppError::Database(format!(
|
||
"数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。"
|
||
)));
|
||
}
|
||
|
||
let result = (|| {
|
||
while version < SCHEMA_VERSION {
|
||
match version {
|
||
0 => {
|
||
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
|
||
Self::migrate_v0_to_v1(conn)?;
|
||
Self::set_user_version(conn, 1)?;
|
||
}
|
||
1 => {
|
||
log::info!(
|
||
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
|
||
);
|
||
Self::migrate_v1_to_v2(conn)?;
|
||
Self::set_user_version(conn, 2)?;
|
||
}
|
||
2 => {
|
||
log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)");
|
||
Self::migrate_v2_to_v3(conn)?;
|
||
Self::set_user_version(conn, 3)?;
|
||
}
|
||
3 => {
|
||
log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
|
||
Self::migrate_v3_to_v4(conn)?;
|
||
Self::set_user_version(conn, 4)?;
|
||
}
|
||
4 => {
|
||
log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
|
||
Self::migrate_v4_to_v5(conn)?;
|
||
Self::set_user_version(conn, 5)?;
|
||
}
|
||
5 => {
|
||
log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一)");
|
||
Self::migrate_v5_to_v6(conn)?;
|
||
Self::set_user_version(conn, 6)?;
|
||
}
|
||
6 => {
|
||
log::info!("迁移数据库从 v6 到 v7(Skills 更新检测支持)");
|
||
Self::migrate_v6_to_v7(conn)?;
|
||
Self::set_user_version(conn, 7)?;
|
||
}
|
||
7 => {
|
||
log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)");
|
||
Self::migrate_v7_to_v8(conn)?;
|
||
Self::set_user_version(conn, 8)?;
|
||
}
|
||
8 => {
|
||
log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
|
||
Self::migrate_v8_to_v9(conn)?;
|
||
Self::set_user_version(conn, 9)?;
|
||
}
|
||
9 => {
|
||
log::info!("迁移数据库从 v9 到 v10(添加 Hermes Agent 支持)");
|
||
Self::migrate_v9_to_v10(conn)?;
|
||
Self::set_user_version(conn, 10)?;
|
||
}
|
||
10 => {
|
||
log::info!("迁移数据库从 v10 到 v11(usage_daily_rollups 保留 request_model 维度)");
|
||
Self::migrate_v10_to_v11(conn)?;
|
||
Self::set_user_version(conn, 11)?;
|
||
}
|
||
11 => {
|
||
log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)");
|
||
Self::migrate_v11_to_v12(conn)?;
|
||
Self::set_user_version(conn, 12)?;
|
||
}
|
||
12 => {
|
||
log::info!("迁移数据库从 v12 到 v13(记录输入 token 缓存语义)");
|
||
Self::migrate_v12_to_v13(conn)?;
|
||
Self::set_user_version(conn, 13)?;
|
||
}
|
||
13 => {
|
||
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
|
||
Self::migrate_v13_to_v14(conn)?;
|
||
Self::set_user_version(conn, 14)?;
|
||
}
|
||
14 => {
|
||
log::info!("迁移数据库从 v14 到 v15(Skills/MCP 添加 Grok Build 支持)");
|
||
Self::migrate_v14_to_v15(conn)?;
|
||
Self::set_user_version(conn, 15)?;
|
||
}
|
||
15 => {
|
||
log::info!("迁移数据库从 v15 到 v16(重建 Codex 会话用量)");
|
||
Self::migrate_v15_to_v16(conn)?;
|
||
Self::set_user_version(conn, 16)?;
|
||
}
|
||
16 => {
|
||
log::info!("迁移数据库从 v16 到 v17(添加会话用量持久去重账本)");
|
||
Self::migrate_v16_to_v17(conn)?;
|
||
Self::set_user_version(conn, 17)?;
|
||
}
|
||
17 => {
|
||
log::info!("迁移数据库从 v17 到 v18(会话日志字节游标列)");
|
||
Self::migrate_v17_to_v18(conn)?;
|
||
Self::set_user_version(conn, 18)?;
|
||
}
|
||
18 => {
|
||
for table in ["mcp_servers", "skills"] {
|
||
if Self::table_exists(conn, table)? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
table,
|
||
"enabled_mcode",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
}
|
||
Self::set_user_version(conn, 19)?;
|
||
}
|
||
19 => {
|
||
log::info!("迁移数据库从 v19 到 v20(MCP 添加 Pi 支持)");
|
||
if Self::table_exists(conn, "mcp_servers")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"mcp_servers",
|
||
"enabled_pi",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
Self::set_user_version(conn, 20)?;
|
||
}
|
||
20 => {
|
||
for table in ["mcp_servers", "skills"] {
|
||
if Self::table_exists(conn, table)? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
table,
|
||
"enabled_agy",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
}
|
||
Self::set_user_version(conn, 21)?;
|
||
}
|
||
21 => {
|
||
for table in ["mcp_servers", "skills"] {
|
||
if Self::table_exists(conn, table)? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
table,
|
||
"enabled_kimi",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
}
|
||
Self::set_user_version(conn, 22)?;
|
||
}
|
||
22 => {
|
||
// Oh My Pi:只有 MCP 存开关;Skills 的启用状态按原生目录现算(同 Pi)。
|
||
if Self::table_exists(conn, "mcp_servers")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"mcp_servers",
|
||
"enabled_omp",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
Self::set_user_version(conn, 23)?;
|
||
}
|
||
_ => {
|
||
return Err(AppError::Database(format!(
|
||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||
)));
|
||
}
|
||
}
|
||
version = Self::get_user_version(conn)?;
|
||
}
|
||
Ok(())
|
||
})();
|
||
|
||
match result {
|
||
Ok(_) => {
|
||
conn.execute("RELEASE schema_migration;", [])
|
||
.map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?;
|
||
Ok(())
|
||
}
|
||
Err(e) => {
|
||
conn.execute("ROLLBACK TO schema_migration;", []).ok();
|
||
conn.execute("RELEASE schema_migration;", []).ok();
|
||
Err(e)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// v0 -> v1 迁移:补齐所有缺失列
|
||
fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {
|
||
// providers 表
|
||
Self::add_column_if_missing(conn, "providers", "category", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?;
|
||
Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?;
|
||
Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"providers",
|
||
"is_current",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
// provider_endpoints 表
|
||
Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?;
|
||
|
||
// mcp_servers 表
|
||
Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"mcp_servers",
|
||
"enabled_codex",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"mcp_servers",
|
||
"enabled_gemini",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
// prompts 表
|
||
Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
||
Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
|
||
Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
|
||
|
||
// skills 表
|
||
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
|
||
|
||
// skill_repos 表
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"skill_repos",
|
||
"branch",
|
||
"TEXT NOT NULL DEFAULT 'main'",
|
||
)?;
|
||
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
||
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
|
||
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
||
// providers 表字段
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"providers",
|
||
"cost_multiplier",
|
||
"TEXT NOT NULL DEFAULT '1.0'",
|
||
)?;
|
||
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
||
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"providers",
|
||
"in_failover_queue",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
// 添加代理超时配置字段
|
||
if Self::table_exists(conn, "proxy_config")? {
|
||
// 兼容旧版本缺失的基础字段
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"proxy_enabled",
|
||
"INTEGER NOT NULL DEFAULT 0",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"listen_address",
|
||
"TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"listen_port",
|
||
"INTEGER NOT NULL DEFAULT 15721",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"enable_logging",
|
||
"INTEGER NOT NULL DEFAULT 1",
|
||
)?;
|
||
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"streaming_first_byte_timeout",
|
||
"INTEGER NOT NULL DEFAULT 60",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"streaming_idle_timeout",
|
||
"INTEGER NOT NULL DEFAULT 120",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"non_streaming_timeout",
|
||
"INTEGER NOT NULL DEFAULT 600",
|
||
)?;
|
||
}
|
||
|
||
// 删除旧的 failover_queue 表(如果存在)
|
||
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
|
||
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
|
||
|
||
// 创建 failover 索引
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||
ON providers(app_type, in_failover_queue, sort_index)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
|
||
|
||
// proxy_request_logs 表
|
||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||
request_model TEXT,
|
||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||
input_token_semantics INTEGER NOT NULL DEFAULT 0,
|
||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||
)", [])?;
|
||
|
||
// 为已存在的表添加新字段
|
||
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_request_logs",
|
||
"is_streaming",
|
||
"INTEGER NOT NULL DEFAULT 0",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_request_logs",
|
||
"cost_multiplier",
|
||
"TEXT NOT NULL DEFAULT '1.0'",
|
||
)?;
|
||
Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
|
||
Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
|
||
|
||
// model_pricing 表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||
)",
|
||
[],
|
||
)?;
|
||
|
||
// 清空并重新插入模型定价
|
||
conn.execute("DELETE FROM model_pricing", [])
|
||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||
Self::seed_model_pricing(conn)?;
|
||
|
||
// 重构 skills 表(添加 app_type 字段)
|
||
Self::migrate_skills_table(conn)?;
|
||
|
||
// 重构 proxy_config 为三行结构(每应用独立配置)
|
||
Self::migrate_proxy_config_to_per_app(conn)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 将 proxy_config 迁移为三行结构(每应用独立配置)
|
||
fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
|
||
// 检查是否已经是新表结构(幂等性)
|
||
if !Self::table_exists(conn, "proxy_config")? {
|
||
// 表不存在,跳过迁移(新安装)
|
||
return Ok(());
|
||
}
|
||
|
||
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||
// 已经是三行结构,跳过迁移
|
||
log::info!("proxy_config 已经是三行结构,跳过迁移");
|
||
return Ok(());
|
||
}
|
||
|
||
// 读取旧配置
|
||
let old_config = conn
|
||
.query_row(
|
||
"SELECT listen_address, listen_port, max_retries, enable_logging,
|
||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||
FROM proxy_config WHERE id = 1",
|
||
[],
|
||
|row| {
|
||
Ok((
|
||
row.get::<_, String>(0)?,
|
||
row.get::<_, i32>(1)?,
|
||
row.get::<_, i32>(2)?,
|
||
row.get::<_, i32>(3)?,
|
||
row.get::<_, i32>(4).unwrap_or(30),
|
||
row.get::<_, i32>(5).unwrap_or(60),
|
||
row.get::<_, i32>(6).unwrap_or(300),
|
||
))
|
||
},
|
||
)
|
||
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
|
||
|
||
let old_cb = conn.query_row(
|
||
"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
|
||
FROM circuit_breaker_config WHERE id = 1", [],
|
||
|row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
|
||
row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
|
||
).unwrap_or((5, 2, 60, 0.5, 10));
|
||
|
||
let get_bool = |key: &str| -> bool {
|
||
conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
|
||
r.get::<_, String>(0)
|
||
})
|
||
.map(|v| v == "true" || v == "1")
|
||
.unwrap_or(false)
|
||
};
|
||
|
||
let apps = [
|
||
(
|
||
"claude",
|
||
get_bool("proxy_takeover_claude"),
|
||
get_bool("auto_failover_enabled_claude"),
|
||
6,
|
||
45,
|
||
90,
|
||
8,
|
||
3,
|
||
90,
|
||
0.6,
|
||
15,
|
||
),
|
||
(
|
||
"codex",
|
||
get_bool("proxy_takeover_codex"),
|
||
get_bool("auto_failover_enabled_codex"),
|
||
3,
|
||
old_config.4,
|
||
old_config.5,
|
||
old_cb.0,
|
||
old_cb.1,
|
||
old_cb.2,
|
||
old_cb.3,
|
||
old_cb.4,
|
||
),
|
||
(
|
||
"gemini",
|
||
get_bool("proxy_takeover_gemini"),
|
||
get_bool("auto_failover_enabled_gemini"),
|
||
5,
|
||
old_config.4,
|
||
old_config.5,
|
||
old_cb.0,
|
||
old_cb.1,
|
||
old_cb.2,
|
||
old_cb.3,
|
||
old_cb.4,
|
||
),
|
||
(
|
||
"grokbuild",
|
||
false,
|
||
false,
|
||
3,
|
||
old_config.4,
|
||
old_config.5,
|
||
old_cb.0,
|
||
old_cb.1,
|
||
old_cb.2,
|
||
old_cb.3,
|
||
old_cb.4,
|
||
),
|
||
];
|
||
|
||
// 创建新表
|
||
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
|
||
conn.execute("CREATE TABLE proxy_config_new (
|
||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
)", [])?;
|
||
|
||
// 插入三行配置
|
||
for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
|
||
conn.execute(
|
||
"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||
enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
|
||
non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||
circuit_error_rate_threshold, circuit_min_requests)
|
||
VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||
rusqlite::params![app, old_config.0, old_config.1, old_config.3,
|
||
if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
|
||
retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
|
||
).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
|
||
}
|
||
|
||
// 替换表并清理
|
||
conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
|
||
conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
|
||
conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
|
||
conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
|
||
conn.execute(
|
||
"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
|
||
[],
|
||
)?;
|
||
|
||
log::info!("proxy_config 已迁移为三行结构");
|
||
Ok(())
|
||
}
|
||
|
||
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
||
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
||
// v3 结构(统一管理架构)已经是更高版本的 skills 表:
|
||
// - 主键为 id
|
||
// - 包含 enabled_claude / enabled_codex / enabled_gemini 等列
|
||
// 在这种情况下,不应再执行 v1 -> v2 的迁移逻辑,否则会因列不匹配而失败。
|
||
if Self::has_column(conn, "skills", "enabled_claude")?
|
||
|| Self::has_column(conn, "skills", "id")?
|
||
{
|
||
log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移");
|
||
return Ok(());
|
||
}
|
||
|
||
// 检查是否已经是新表结构
|
||
if Self::has_column(conn, "skills", "app_type")? {
|
||
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
||
return Ok(());
|
||
}
|
||
|
||
log::info!("开始迁移 skills 表...");
|
||
|
||
// 1. 重命名旧表
|
||
conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
|
||
.map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
|
||
|
||
// 2. 创建新表
|
||
conn.execute(
|
||
"CREATE TABLE skills (
|
||
directory TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
installed BOOLEAN NOT NULL DEFAULT 0,
|
||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (directory, app_type)
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
||
|
||
// 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo")
|
||
// 旧数据如果没有前缀,默认为 claude
|
||
let mut stmt = conn
|
||
.prepare("SELECT key, installed, installed_at FROM skills_old")
|
||
.map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
|
||
|
||
let old_skills: Vec<(String, bool, i64)> = stmt
|
||
.query_map([], |row| {
|
||
Ok((
|
||
row.get::<_, String>(0)?,
|
||
row.get::<_, bool>(1)?,
|
||
row.get::<_, i64>(2)?,
|
||
))
|
||
})
|
||
.map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
|
||
|
||
let count = old_skills.len();
|
||
|
||
for (key, installed, installed_at) in old_skills {
|
||
// 解析 key: "app:directory" 或 "directory"(默认 claude)
|
||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
||
let (app, dir) = key.split_at(idx);
|
||
(app.to_string(), dir[1..].to_string()) // 跳过冒号
|
||
} else {
|
||
("claude".to_string(), key.clone())
|
||
};
|
||
|
||
conn.execute(
|
||
"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||
rusqlite::params![directory, app_type, installed, installed_at],
|
||
)
|
||
.map_err(|e| {
|
||
AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
|
||
})?;
|
||
}
|
||
|
||
// 4. 删除旧表
|
||
conn.execute("DROP TABLE skills_old", [])
|
||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
||
|
||
log::info!("skills 表迁移完成,共迁移 {count} 条记录");
|
||
Ok(())
|
||
}
|
||
|
||
/// v2 -> v3 迁移:Skills 统一管理架构
|
||
///
|
||
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
||
/// 支持三应用启用标志(enabled_claude, enabled_codex, enabled_gemini)。
|
||
///
|
||
/// 迁移策略:
|
||
/// 1. 旧数据库只存储安装记录,真正的 skill 文件在文件系统
|
||
/// 2. 直接重建新表结构,后续由 SkillService 在首次启动时扫描文件系统重建数据
|
||
fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
|
||
// 检查是否已经是新结构(通过检查是否有 enabled_claude 列)
|
||
if Self::has_column(conn, "skills", "enabled_claude")? {
|
||
log::info!("skills 表已经是 v3 结构,跳过迁移");
|
||
return Ok(());
|
||
}
|
||
|
||
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
|
||
|
||
// 1. 备份旧数据(用于日志和后续启动迁移)
|
||
let old_count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
|
||
.unwrap_or(0);
|
||
log::info!("旧 skills 表有 {old_count} 条记录");
|
||
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT directory, app_type FROM skills
|
||
WHERE installed = 1",
|
||
)
|
||
.map_err(|e| AppError::Database(format!("查询旧 skills 快照失败: {e}")))?;
|
||
let snapshot_rows: Vec<LegacySkillMigrationRow> = stmt
|
||
.query_map([], |row| {
|
||
Ok(LegacySkillMigrationRow {
|
||
directory: row.get(0)?,
|
||
app_type: row.get(1)?,
|
||
})
|
||
})
|
||
.map_err(|e| AppError::Database(format!("读取旧 skills 快照失败: {e}")))?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(|e| AppError::Database(format!("解析旧 skills 快照失败: {e}")))?;
|
||
let snapshot_json = serde_json::to_string(&snapshot_rows)
|
||
.map_err(|e| AppError::Database(format!("序列化旧 skills 快照失败: {e}")))?;
|
||
|
||
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
|
||
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/,
|
||
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
|
||
let _ = conn.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
|
||
[],
|
||
);
|
||
let _ = conn.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_snapshot', ?1)",
|
||
[snapshot_json],
|
||
);
|
||
|
||
// 2. 删除旧表
|
||
conn.execute("DROP TABLE IF EXISTS skills", [])
|
||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
||
|
||
// 3. 创建新表
|
||
conn.execute(
|
||
"CREATE TABLE skills (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
directory TEXT NOT NULL,
|
||
repo_owner TEXT,
|
||
repo_name TEXT,
|
||
repo_branch TEXT DEFAULT 'main',
|
||
readme_url TEXT,
|
||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||
installed_at INTEGER NOT NULL DEFAULT 0
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
||
|
||
log::info!(
|
||
"skills 表已迁移到 v3 结构。\n\
|
||
注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。"
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// v3 -> v4 迁移:添加 OpenCode 支持
|
||
///
|
||
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
|
||
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
||
// 为 mcp_servers 表添加 enabled_opencode 列
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"mcp_servers",
|
||
"enabled_opencode",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
// 为 skills 表添加 enabled_opencode 列
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"skills",
|
||
"enabled_opencode",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
|
||
Ok(())
|
||
}
|
||
|
||
/// v4 -> v5 迁移:新增计费模式配置与请求模型字段
|
||
fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
|
||
if Self::table_exists(conn, "proxy_config")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"default_cost_multiplier",
|
||
"TEXT NOT NULL DEFAULT '1'",
|
||
)?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_config",
|
||
"pricing_model_source",
|
||
"TEXT NOT NULL DEFAULT 'response'",
|
||
)?;
|
||
}
|
||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||
Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
|
||
}
|
||
|
||
log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
|
||
Ok(())
|
||
}
|
||
|
||
/// v5 -> v6 迁移:添加使用量日聚合表 + 统一 Copilot 模板类型
|
||
fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
|
||
// 1. 添加使用量日聚合表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||
date TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
provider_id TEXT NOT NULL,
|
||
model TEXT NOT NULL,
|
||
request_count INTEGER NOT NULL DEFAULT 0,
|
||
success_count INTEGER NOT NULL DEFAULT 0,
|
||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (date, app_type, provider_id, model)
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
|
||
|
||
// 2. 统一 Copilot 模板类型为 github_copilot
|
||
let mut stmt = conn
|
||
.prepare("SELECT id, app_type, meta FROM providers")
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let rows = stmt
|
||
.query_map([], |row| {
|
||
Ok((
|
||
row.get::<_, String>(0)?,
|
||
row.get::<_, String>(1)?,
|
||
row.get::<_, String>(2)?,
|
||
))
|
||
})
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut updates = Vec::new();
|
||
for row in rows {
|
||
let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
|
||
let mut updated = false;
|
||
|
||
if let Some(usage_script) = meta.get_mut("usage_script") {
|
||
if let Some(template_type) = usage_script.get_mut("template_type") {
|
||
if template_type == "copilot" {
|
||
*template_type =
|
||
serde_json::Value::String("github_copilot".to_string());
|
||
updated = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if updated {
|
||
let new_meta_str = serde_json::to_string(&meta)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
updates.push((id, app_type, new_meta_str));
|
||
}
|
||
}
|
||
}
|
||
|
||
for (id, app_type, new_meta) in updates {
|
||
conn.execute(
|
||
"UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3",
|
||
params![new_meta, id, app_type],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
}
|
||
|
||
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表,统一 copilot 模板类型");
|
||
Ok(())
|
||
}
|
||
|
||
/// v6 -> v7: Skills 更新检测支持(content_hash + updated_at)
|
||
fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> {
|
||
if Self::table_exists(conn, "skills")? {
|
||
Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"skills",
|
||
"updated_at",
|
||
"INTEGER NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列");
|
||
Ok(())
|
||
}
|
||
|
||
/// v7 -> v8: 会话日志使用追踪(无代理模式统计支持)
|
||
fn migrate_v7_to_v8(conn: &Connection) -> Result<(), AppError> {
|
||
// 1. 为 proxy_request_logs 添加 data_source 列,区分数据来源
|
||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_request_logs",
|
||
"data_source",
|
||
"TEXT NOT NULL DEFAULT 'proxy'",
|
||
)?;
|
||
Self::create_request_logs_usage_indexes_if_supported(conn)?;
|
||
}
|
||
|
||
// 2. 创建会话日志同步状态表
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS session_log_sync (
|
||
file_path TEXT PRIMARY KEY,
|
||
last_modified INTEGER NOT NULL,
|
||
last_line_offset INTEGER NOT NULL DEFAULT 0,
|
||
last_synced_at INTEGER NOT NULL
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?;
|
||
|
||
// 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD
|
||
if Self::table_exists(conn, "model_pricing")? {
|
||
let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[
|
||
("deepseek-v3.2", "0.28", "0.42", "0.028", "0"),
|
||
("deepseek-v3.1", "0.55", "1.67", "0.055", "0"),
|
||
("deepseek-v3", "0.28", "1.11", "0.028", "0"),
|
||
("doubao-seed-code", "0.17", "1.11", "0.02", "0"),
|
||
("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"),
|
||
("kimi-k2-0905", "0.55", "2.20", "0.10", "0"),
|
||
("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"),
|
||
("minimax-m2.1", "0.27", "0.95", "0.03", "0"),
|
||
("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"),
|
||
("minimax-m2", "0.27", "0.95", "0.03", "0"),
|
||
("glm-4.7", "0.39", "1.75", "0.04", "0"),
|
||
("glm-4.6", "0.28", "1.11", "0.03", "0"),
|
||
("mimo-v2-flash", "0.09", "0.29", "0.009", "0"),
|
||
];
|
||
for (model_id, input, output, cache_read, cache_creation) in pricing_fixes {
|
||
conn.execute(
|
||
"UPDATE model_pricing SET
|
||
input_cost_per_million = ?2,
|
||
output_cost_per_million = ?3,
|
||
cache_read_cost_per_million = ?4,
|
||
cache_creation_cost_per_million = ?5
|
||
WHERE model_id = ?1",
|
||
rusqlite::params![model_id, input, output, cache_read, cache_creation],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?;
|
||
}
|
||
}
|
||
|
||
log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价");
|
||
Ok(())
|
||
}
|
||
|
||
/// v8 → v9: 全面补充模型定价(清空 + 重新 seed)
|
||
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
|
||
conn.execute("DELETE FROM model_pricing", [])
|
||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||
Self::seed_model_pricing(conn)?;
|
||
log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
|
||
Ok(())
|
||
}
|
||
|
||
/// v9 -> v10 迁移:添加 Hermes Agent 支持
|
||
fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"mcp_servers",
|
||
"enabled_hermes",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
// skills table may not exist in databases migrated from very old versions
|
||
if Self::table_exists(conn, "skills")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"skills",
|
||
"enabled_hermes",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
|
||
log::info!("v9 -> v10 迁移完成:已添加 Hermes Agent 支持");
|
||
Ok(())
|
||
}
|
||
|
||
/// v10 -> v11:usage_daily_rollups 增加 request_model 维度(进入主键),
|
||
/// proxy_request_logs 增加 pricing_model 列(写入时的计价基准,回填依据)。
|
||
///
|
||
/// 路由接管下 model(真实上游模型)≠ request_model(客户端别名),
|
||
/// 旧 rollup 只按 model 聚合,明细 prune 后映射关系永久丢失、计费不可审计。
|
||
/// SQLite 改主键必须重建表;历史行的 request_model 已不可知,填 ''。
|
||
fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> {
|
||
// proxy_request_logs.pricing_model:NULL = v11 前的历史行(回填走
|
||
// model → 占位符回退 request_model 的旧逻辑),'' = 未计价的错误行
|
||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||
Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?;
|
||
}
|
||
|
||
if !Self::table_exists(conn, "usage_daily_rollups")? {
|
||
log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建");
|
||
return Ok(());
|
||
}
|
||
|
||
conn.execute_batch(
|
||
"ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10;
|
||
CREATE TABLE usage_daily_rollups (
|
||
date TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
provider_id TEXT NOT NULL,
|
||
model TEXT NOT NULL,
|
||
request_model TEXT NOT NULL DEFAULT '',
|
||
pricing_model TEXT NOT NULL DEFAULT '',
|
||
request_count INTEGER NOT NULL DEFAULT 0,
|
||
success_count INTEGER NOT NULL DEFAULT 0,
|
||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||
);
|
||
INSERT INTO usage_daily_rollups
|
||
(date, app_type, provider_id, model, request_model, pricing_model,
|
||
request_count, success_count, input_tokens, output_tokens,
|
||
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms)
|
||
SELECT date, app_type, provider_id, model, '', '',
|
||
request_count, success_count, input_tokens, output_tokens,
|
||
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
|
||
FROM usage_daily_rollups_v10;
|
||
DROP TABLE usage_daily_rollups_v10;",
|
||
)
|
||
.map_err(|e| {
|
||
AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}"))
|
||
})?;
|
||
|
||
log::info!(
|
||
"v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// v11 -> v12 迁移:添加项目 Profiles 表
|
||
/// 与 create_tables_on_conn 中的建表语句保持一致(IF NOT EXISTS 保证幂等)
|
||
fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> {
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS profiles (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
payload TEXT NOT NULL,
|
||
sort_order INTEGER,
|
||
created_at INTEGER,
|
||
updated_at INTEGER
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("v11 -> v12 创建 profiles 表失败: {e}")))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// v12 -> v13:记录 input_tokens 是否包含缓存写入。
|
||
///
|
||
/// 默认 0 表示旧版/未知语义;旧 Codex 行只包含 cache read,不包含
|
||
/// cache creation。新代理行会显式写入 1(total-inclusive) 或 2(fresh)。
|
||
fn migrate_v12_to_v13(conn: &Connection) -> Result<(), AppError> {
|
||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"proxy_request_logs",
|
||
"input_token_semantics",
|
||
"INTEGER NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
if Self::table_exists(conn, "usage_daily_rollups")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"usage_daily_rollups",
|
||
"input_token_semantics",
|
||
"INTEGER NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
|
||
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
|
||
if !Self::table_exists(conn, "proxy_config")? {
|
||
return Ok(());
|
||
}
|
||
|
||
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"CREATE TABLE proxy_config_v14 (
|
||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||
proxy_enabled INTEGER NOT NULL DEFAULT 0,
|
||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||
listen_port INTEGER NOT NULL DEFAULT 15721,
|
||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||
enabled INTEGER NOT NULL DEFAULT 0,
|
||
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
|
||
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
|
||
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
||
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let copied_columns = [
|
||
("app_type", "'claude'"),
|
||
("proxy_enabled", "0"),
|
||
("listen_address", "'127.0.0.1'"),
|
||
("listen_port", "15721"),
|
||
("enable_logging", "1"),
|
||
("enabled", "0"),
|
||
("auto_failover_enabled", "0"),
|
||
("max_retries", "3"),
|
||
("streaming_first_byte_timeout", "60"),
|
||
("streaming_idle_timeout", "120"),
|
||
("non_streaming_timeout", "600"),
|
||
("circuit_failure_threshold", "4"),
|
||
("circuit_success_threshold", "2"),
|
||
("circuit_timeout_seconds", "60"),
|
||
("circuit_error_rate_threshold", "0.6"),
|
||
("circuit_min_requests", "10"),
|
||
("default_cost_multiplier", "'1'"),
|
||
("pricing_model_source", "'response'"),
|
||
("live_takeover_active", "0"),
|
||
("created_at", "datetime('now')"),
|
||
("updated_at", "datetime('now')"),
|
||
]
|
||
.into_iter()
|
||
.map(|(column, fallback)| {
|
||
Self::has_column(conn, "proxy_config", column).map(|exists| {
|
||
if exists {
|
||
format!("\"{column}\"")
|
||
} else {
|
||
fallback.into()
|
||
}
|
||
})
|
||
})
|
||
.collect::<Result<Vec<_>, AppError>>()?
|
||
.join(", ");
|
||
|
||
let copy_sql = format!(
|
||
"INSERT INTO proxy_config_v14 (
|
||
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||
enabled, auto_failover_enabled, max_retries,
|
||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||
circuit_error_rate_threshold, circuit_min_requests,
|
||
default_cost_multiplier, pricing_model_source, live_takeover_active,
|
||
created_at, updated_at
|
||
)
|
||
SELECT {copied_columns} FROM proxy_config"
|
||
);
|
||
conn.execute(©_sql, [])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
conn.execute("DROP TABLE proxy_config", [])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
|
||
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
|
||
if Self::table_exists(conn, "mcp_servers")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"mcp_servers",
|
||
"enabled_grokbuild",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
if Self::table_exists(conn, "skills")? {
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"skills",
|
||
"enabled_grokbuild",
|
||
"BOOLEAN NOT NULL DEFAULT 0",
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// v15 -> v16: remove Codex session rows and cursors so startup sync can
|
||
/// rebuild them with fork-history alignment. Must stay connection-level:
|
||
/// schema migration already owns the Database connection mutex.
|
||
fn migrate_v15_to_v16(conn: &Connection) -> Result<(), AppError> {
|
||
let codex_dir = crate::codex_config::get_codex_config_dir();
|
||
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
|
||
}
|
||
|
||
/// v16 -> v17: preserve session request identities after detail rollup.
|
||
fn migrate_v16_to_v17(conn: &Connection) -> Result<(), AppError> {
|
||
conn.execute_batch(
|
||
"CREATE TABLE IF NOT EXISTS session_usage_dedup (
|
||
data_source TEXT NOT NULL,
|
||
request_id TEXT NOT NULL,
|
||
semantic_id TEXT NOT NULL,
|
||
has_entry_id INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (data_source, request_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_session_usage_dedup_semantic
|
||
ON session_usage_dedup(data_source, semantic_id, has_entry_id);",
|
||
)
|
||
.map_err(|error| AppError::Database(format!("创建会话用量去重账本失败: {error}")))
|
||
}
|
||
|
||
/// v17 -> v18: Claude 会话日志的字节游标列与尾部指纹列。
|
||
///
|
||
/// 独立成版而非搭 v17 车:v17 已在开发库上执行过(迁移不会重跑,
|
||
/// `CREATE TABLE IF NOT EXISTS` 也不补列),追加进 v17 会让这些库
|
||
/// 永远缺列。存量行保持 NULL,首轮扫描按旧行号游标转换为字节位置
|
||
/// 后继续增量;之后写入字节偏移走 seek 增量,并记录游标边界前的
|
||
/// 尾部指纹用于识别外部重写(截断由 size 检测,同尺寸/更大的替换
|
||
/// 只有指纹能发现)。
|
||
fn migrate_v17_to_v18(conn: &Connection) -> Result<(), AppError> {
|
||
// 缺表的库(异常/测试夹具)跳过:create_tables 会以含列的新 DDL 建表。
|
||
if Self::table_exists(conn, "session_log_sync")? {
|
||
Self::add_column_if_missing(conn, "session_log_sync", "last_byte_offset", "INTEGER")?;
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"session_log_sync",
|
||
"last_tail_fingerprint",
|
||
"INTEGER",
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 插入默认模型定价数据
|
||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||
let pricing_data = [
|
||
// Claude Fable 5.1 / Mythos 5.1(2026-09-01 发布;同 Fable 5 价,
|
||
// 但缓存读为 0.025x = $0.25,非 Fable 5 的 $1)
|
||
(
|
||
"claude-fable-5-1",
|
||
"Claude Fable 5.1",
|
||
"10",
|
||
"50",
|
||
"0.25",
|
||
"12.50",
|
||
),
|
||
(
|
||
"claude-mythos-5-1",
|
||
"Claude Mythos 5.1",
|
||
"10",
|
||
"50",
|
||
"0.25",
|
||
"12.50",
|
||
),
|
||
// Claude Fable 5(Opus 之上的新档)
|
||
(
|
||
"claude-fable-5",
|
||
"Claude Fable 5",
|
||
"10",
|
||
"50",
|
||
"1.00",
|
||
"12.50",
|
||
),
|
||
(
|
||
"claude-mythos-5",
|
||
"Claude Mythos 5",
|
||
"10",
|
||
"50",
|
||
"1.00",
|
||
"12.50",
|
||
),
|
||
// Claude Opus 5.5(2026-09-23 发布;缓存读为 0.05x = $0.20,非常规 0.1x 的
|
||
// $0.40,也非 Opus 5 的 $0.50;fast mode $8/$40 不入表)
|
||
("claude-opus-5-5", "Claude Opus 5.5", "4", "20", "0.20", "5"),
|
||
// Claude Opus 5(与 Opus 4.8 同价位;fast mode $10/$50 不入表)
|
||
("claude-opus-5", "Claude Opus 5", "5", "25", "0.50", "6.25"),
|
||
// Claude 4.8 系列
|
||
(
|
||
"claude-opus-4-8",
|
||
"Claude Opus 4.8",
|
||
"5",
|
||
"25",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
// Claude Sonnet 5.5(2026-09-28 发布;同 Sonnet 5 价,但缓存读为
|
||
// 0.05x = $0.10,非 Sonnet 5 的 $0.20)
|
||
(
|
||
"claude-sonnet-5-5",
|
||
"Claude Sonnet 5.5",
|
||
"2",
|
||
"10",
|
||
"0.10",
|
||
"2.50",
|
||
),
|
||
// Claude Haiku 5.5(2026-10-07 发布;按提示词长度分档,表内只存
|
||
// 10 万 token 以内的标准档,超过时 $0.50/$2.50 的高档不入表)
|
||
(
|
||
"claude-haiku-5-5",
|
||
"Claude Haiku 5.5",
|
||
"0.10",
|
||
"0.50",
|
||
"0.01",
|
||
"0.125",
|
||
),
|
||
// Claude Sonnet 5(官方定价页 2026-09 确认:$2/$10 介绍价转为正式价,
|
||
// 原定 09-01 涨至 $3/$15 取消)
|
||
(
|
||
"claude-sonnet-5",
|
||
"Claude Sonnet 5",
|
||
"2",
|
||
"10",
|
||
"0.20",
|
||
"2.50",
|
||
),
|
||
// Claude 4.7 系列
|
||
(
|
||
"claude-opus-4-7",
|
||
"Claude Opus 4.7",
|
||
"5",
|
||
"25",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
// Claude 4.6 系列(裸 id 行覆盖无日期后缀的日志变体,与 dated 行同价)
|
||
(
|
||
"claude-opus-4-6",
|
||
"Claude Opus 4.6",
|
||
"5",
|
||
"25",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"claude-sonnet-4-6",
|
||
"Claude Sonnet 4.6",
|
||
"3",
|
||
"15",
|
||
"0.30",
|
||
"3.75",
|
||
),
|
||
(
|
||
"claude-opus-4-6-20260206",
|
||
"Claude Opus 4.6",
|
||
"5",
|
||
"25",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"claude-sonnet-4-6-20260217",
|
||
"Claude Sonnet 4.6",
|
||
"3",
|
||
"15",
|
||
"0.30",
|
||
"3.75",
|
||
),
|
||
// Claude 4.5 系列
|
||
(
|
||
"claude-opus-4-5-20251101",
|
||
"Claude Opus 4.5",
|
||
"5",
|
||
"25",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"claude-sonnet-4-5-20250929",
|
||
"Claude Sonnet 4.5",
|
||
"3",
|
||
"15",
|
||
"0.30",
|
||
"3.75",
|
||
),
|
||
(
|
||
"claude-haiku-4-5-20251001",
|
||
"Claude Haiku 4.5",
|
||
"1",
|
||
"5",
|
||
"0.10",
|
||
"1.25",
|
||
),
|
||
// Claude 4 系列 (Legacy Models)
|
||
(
|
||
"claude-opus-4-20250514",
|
||
"Claude Opus 4",
|
||
"15",
|
||
"75",
|
||
"1.50",
|
||
"18.75",
|
||
),
|
||
(
|
||
"claude-opus-4-1-20250805",
|
||
"Claude Opus 4.1",
|
||
"15",
|
||
"75",
|
||
"1.50",
|
||
"18.75",
|
||
),
|
||
(
|
||
"claude-sonnet-4-20250514",
|
||
"Claude Sonnet 4",
|
||
"3",
|
||
"15",
|
||
"0.30",
|
||
"3.75",
|
||
),
|
||
// Claude 3.5 系列
|
||
(
|
||
"claude-3-5-haiku-20241022",
|
||
"Claude 3.5 Haiku",
|
||
"0.80",
|
||
"4",
|
||
"0.08",
|
||
"1",
|
||
),
|
||
(
|
||
"claude-3-5-sonnet-20241022",
|
||
"Claude 3.5 Sonnet",
|
||
"3",
|
||
"15",
|
||
"0.30",
|
||
"3.75",
|
||
),
|
||
// GPT-6 系列(Astra 2026-09-04 发布,1.05M 窗口;Sol / Luna 2026-09-22 发布)
|
||
// 2026-09-23 核对官方价页 + 模型页 + models.dev:录入 Standard 短上下文价,
|
||
// cache read 0.1×、cache write 1.25× 输入价。>272K 长上下文档(输入与缓存 2×、输出 1.5×)、
|
||
// Batch/Flex、Fast mode、区域加价本表无法表达,与 gpt-5.5 同样忽略。
|
||
// effort 档 low/medium/high/xhigh 由查价剥后缀回落到本行;max 不在剥离列表
|
||
//(会与 *-max 真 id 撞名),不另加后缀行。
|
||
("gpt-6-astra", "GPT-6 Astra", "10", "50", "1", "12.5"),
|
||
// GPT-6.1 Sol: Standard short-context pricing; cached input is 0.05× input.
|
||
// https://developers.openai.com/api/docs/models/gpt-6.1-sol
|
||
("gpt-6.1-sol", "GPT-6.1 Sol", "2", "10", "0.10", "2.50"),
|
||
("gpt-6-sol", "GPT-6 Sol", "2", "10", "0.20", "2.50"),
|
||
("gpt-6-luna", "GPT-6 Luna", "0.10", "0.50", "0.01", "0.125"),
|
||
// GPT-5.6 系列(Sol / Terra / Luna,2026-06 发布)
|
||
// 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列)
|
||
// 2026-09-06 审计:Sol 改促销价 4/20/0.40/5(OpenAI 价页原文"至少持续到 2026-11-21"),
|
||
// 挂牌价 5/30/0.50/6.25。录促销价、不进豁免表:促销结束 models.dev 更新后审计会自动报出。
|
||
("gpt-5.6-sol", "GPT-5.6 Sol", "4", "20", "0.40", "5"),
|
||
// 2026-07-30 OpenAI 降价:luna -80%、terra -20%,sol 不变(Fast mode 2× 价不入表)
|
||
("gpt-5.6-terra", "GPT-5.6 Terra", "2", "12", "0.20", "2.50"),
|
||
(
|
||
"gpt-5.6-luna",
|
||
"GPT-5.6 Luna",
|
||
"0.20",
|
||
"1.20",
|
||
"0.02",
|
||
"0.25",
|
||
),
|
||
// GPT-5.6 Cyber(Daybreak 计划的网安模型,需 Trusted Access;2026-09-23 核对官方价页)。
|
||
// 别名 gpt-daybreak-red-latest / gpt-daybreak-blue-latest 当前分别指向 gpt-5.6-cyber /
|
||
// gpt-5.6-sol,官方明说别名改指向时价格随之改变,故别名不入表。
|
||
(
|
||
"gpt-5.6-cyber",
|
||
"GPT-5.6 Cyber",
|
||
"12.50",
|
||
"75",
|
||
"1.25",
|
||
"15.625",
|
||
),
|
||
// 裸名 gpt-5.6 是 sol 的官方别名;effort 后缀对齐 gpt-5.5 系列的记账形态。
|
||
// 查价先精确匹配 id 再剥 effort 后缀,这些行必须与 sol 同步改价,否则旧价会压过基础行。
|
||
("gpt-5.6", "GPT-5.6 Sol", "4", "20", "0.40", "5"),
|
||
("gpt-5.6-low", "GPT-5.6 Sol", "4", "20", "0.40", "5"),
|
||
("gpt-5.6-medium", "GPT-5.6 Sol", "4", "20", "0.40", "5"),
|
||
("gpt-5.6-high", "GPT-5.6 Sol", "4", "20", "0.40", "5"),
|
||
("gpt-5.6-xhigh", "GPT-5.6 Sol", "4", "20", "0.40", "5"),
|
||
("gpt-5.6-minimal", "GPT-5.6 Sol", "4", "20", "0.40", "5"),
|
||
// GPT-5.5 系列
|
||
("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"),
|
||
("gpt-5.5-low", "GPT-5.5", "5", "30", "0.50", "0"),
|
||
("gpt-5.5-medium", "GPT-5.5", "5", "30", "0.50", "0"),
|
||
("gpt-5.5-high", "GPT-5.5", "5", "30", "0.50", "0"),
|
||
("gpt-5.5-xhigh", "GPT-5.5", "5", "30", "0.50", "0"),
|
||
("gpt-5.5-minimal", "GPT-5.5", "5", "30", "0.50", "0"),
|
||
// GPT-5.4 系列
|
||
("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
|
||
("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
|
||
("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"),
|
||
// GPT-5.2 系列
|
||
("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||
("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||
("gpt-5.2-medium", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||
("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||
("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||
("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
|
||
(
|
||
"gpt-5.2-codex-low",
|
||
"GPT-5.2 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.2-codex-medium",
|
||
"GPT-5.2 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.2-codex-high",
|
||
"GPT-5.2 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.2-codex-xhigh",
|
||
"GPT-5.2 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
// GPT-5.3 Codex 系列
|
||
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
|
||
(
|
||
"gpt-5.3-codex-spark",
|
||
"GPT-5.3 Codex Spark",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.3-codex-low",
|
||
"GPT-5.3 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.3-codex-medium",
|
||
"GPT-5.3 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.3-codex-high",
|
||
"GPT-5.3 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.3-codex-xhigh",
|
||
"GPT-5.3 Codex",
|
||
"1.75",
|
||
"14",
|
||
"0.175",
|
||
"0",
|
||
),
|
||
// GPT-5.1 系列
|
||
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||
("gpt-5.1-medium", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||
("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||
("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||
("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
||
(
|
||
"gpt-5.1-codex-mini",
|
||
"GPT-5.1 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.1-codex-max",
|
||
"GPT-5.1 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.1-codex-max-high",
|
||
"GPT-5.1 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.1-codex-max-xhigh",
|
||
"GPT-5.1 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
// GPT-5 系列
|
||
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
|
||
("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"),
|
||
("gpt-5-medium", "GPT-5", "1.25", "10", "0.125", "0"),
|
||
("gpt-5-high", "GPT-5", "1.25", "10", "0.125", "0"),
|
||
("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"),
|
||
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||
("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||
(
|
||
"gpt-5-codex-medium",
|
||
"GPT-5 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5-codex-high",
|
||
"GPT-5 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5-codex-mini",
|
||
"GPT-5 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5-codex-mini-medium",
|
||
"GPT-5 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5-codex-mini-high",
|
||
"GPT-5 Codex",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
// OpenAI Reasoning 系列
|
||
("o3", "OpenAI o3", "2", "8", "0.50", "0"),
|
||
("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"),
|
||
// GPT-4.1 系列
|
||
("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
|
||
("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
|
||
("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
|
||
// OpenAI 文本模型补全(2026-09-23):各模型页的标准 token 价。
|
||
// 来源:https://developers.openai.com/api/docs/models/<model_id>
|
||
// 已按 /api/docs/deprecations 排除弃用模型;不含工具调用费及多模态价格。
|
||
// Pro 系列未提供缓存折扣,与 o3-pro 一样将未支持的缓存价格记为 0。
|
||
// 有意不收:chat-latest 是滚动别名(改指向即改价);gpt-rosalind-research 官方
|
||
// 2026-10-05 才开始计费且仅限 Trusted Access,提前入表会给免费期用量记账。
|
||
("gpt-5.5-pro", "GPT-5.5 Pro", "30", "180", "0", "0"),
|
||
("gpt-5.4-pro", "GPT-5.4 Pro", "30", "180", "0", "0"),
|
||
("gpt-5.2-pro", "GPT-5.2 Pro", "21", "168", "0", "0"),
|
||
("gpt-4o", "GPT-4o", "2.50", "10", "1.25", "0"),
|
||
("gpt-4o-mini", "GPT-4o Mini", "0.15", "0.60", "0.075", "0"),
|
||
// Gemini 4 系列
|
||
// 来源:https://ai.google.dev/gemini-api/docs/pricing#gemini-4-argon
|
||
// 录的是标准档介绍价(2026-10-15 抓取):输入 2.00 / 输出 10.00 / 上下文缓存读 0.10
|
||
// (缓存仅支持隐式,不支持显式写入,cache_creation 记 0,与其他 Gemini 条目口径一致)。
|
||
// ⚠️ 介绍价 2026-11-30 到期,2026-12-01 起恢复挂牌价 4.00/20.00/0.20。
|
||
// 到期后需走 seed + repair 双写改回;刻意不进 audit-ignore.json,
|
||
// 由 /jason-update-model 审计的 A 段在到期后报出提醒,口径同 3.6/3.7/3.8 Flash。
|
||
// 免费层级不可用;批量档(输入 1.00→2.00 / 输出 5.00→10.00)与 Flex 档不入表,
|
||
// 仓库现有口径仅录标准档。
|
||
(
|
||
"gemini-4-argon",
|
||
"Gemini 4 Argon",
|
||
"2.00",
|
||
"10.00",
|
||
"0.10",
|
||
"0",
|
||
),
|
||
// Gemini 3.8 系列(2026-09-02 发布,1M 窗口)
|
||
// 介绍价 0.75/3.75/0.075 至 2026-12-31,2027-01-01 起挂牌价 1.50/7.50/0.15;口径同 3.7 Flash,勿加豁免。
|
||
(
|
||
"gemini-3.8-flash",
|
||
"Gemini 3.8 Flash",
|
||
"0.75",
|
||
"3.75",
|
||
"0.075",
|
||
"0",
|
||
),
|
||
// Gemini 3.7 系列
|
||
// 录的是介绍价(官方公告 + ai.google.dev 价表 + models.dev 三源一致)。
|
||
// ⚠️ 介绍价 2026-12-31 到期,2027-01-01 起恢复挂牌价 1.50/7.50/0.15(3.6/3.8 Flash 同此规则)。
|
||
// 到期后需走 seed + repair 双写改回;届时 models.dev 会先更新,
|
||
// /jason-update-model 审计的 A 段会自动报出这一行作为提醒——
|
||
// 因此这一行刻意不进 audit-ignore.json,勿加豁免(会屏蔽掉该提醒)。
|
||
(
|
||
"gemini-3.7-flash",
|
||
"Gemini 3.7 Flash",
|
||
"0.75",
|
||
"3.75",
|
||
"0.075",
|
||
"0",
|
||
),
|
||
// Gemini 3.6 系列
|
||
// 2026-09-06 审计:Google 价页已把 3.6 Flash 也改成介绍价 0.75/3.75/0.075(至 2026-12-31),
|
||
// 2027-01-01 起恢复挂牌价 1.50/7.50/0.15。与 3.7/3.8 Flash 同口径,刻意不进 audit-ignore.json。
|
||
(
|
||
"gemini-3.6-flash",
|
||
"Gemini 3.6 Flash",
|
||
"0.75",
|
||
"3.75",
|
||
"0.075",
|
||
"0",
|
||
),
|
||
// Gemini 3.5 系列
|
||
(
|
||
"gemini-3.5-flash",
|
||
"Gemini 3.5 Flash",
|
||
"1.50",
|
||
"9.00",
|
||
"0.15",
|
||
"0",
|
||
),
|
||
(
|
||
"gemini-3.5-flash-lite",
|
||
"Gemini 3.5 Flash Lite",
|
||
"0.30",
|
||
"2.50",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
// Gemini 3.1 系列
|
||
(
|
||
"gemini-3.1-pro-preview",
|
||
"Gemini 3.1 Pro Preview",
|
||
"2",
|
||
"12",
|
||
"0.20",
|
||
"0",
|
||
),
|
||
(
|
||
"gemini-3.1-flash-lite",
|
||
"Gemini 3.1 Flash Lite",
|
||
"0.25",
|
||
"1.50",
|
||
"0.025",
|
||
"0",
|
||
),
|
||
(
|
||
"gemini-3.1-flash-lite-preview",
|
||
"Gemini 3.1 Flash Lite Preview",
|
||
"0.25",
|
||
"1.50",
|
||
"0.025",
|
||
"0",
|
||
),
|
||
// Gemini 3 系列
|
||
(
|
||
"gemini-3-pro-preview",
|
||
"Gemini 3 Pro Preview",
|
||
"2",
|
||
"12",
|
||
"0.2",
|
||
"0",
|
||
),
|
||
(
|
||
"gemini-3-flash-preview",
|
||
"Gemini 3 Flash Preview",
|
||
"0.5",
|
||
"3",
|
||
"0.05",
|
||
"0",
|
||
),
|
||
// Gemini 2.5 系列
|
||
(
|
||
"gemini-2.5-pro",
|
||
"Gemini 2.5 Pro",
|
||
"1.25",
|
||
"10",
|
||
"0.125",
|
||
"0",
|
||
),
|
||
(
|
||
"gemini-2.5-flash",
|
||
"Gemini 2.5 Flash",
|
||
"0.3",
|
||
"2.5",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
(
|
||
"gemini-2.5-flash-lite",
|
||
"Gemini 2.5 Flash Lite",
|
||
"0.10",
|
||
"0.40",
|
||
"0.01",
|
||
"0",
|
||
),
|
||
// Gemini 2.0 系列
|
||
(
|
||
"gemini-2.0-flash",
|
||
"Gemini 2.0 Flash",
|
||
"0.10",
|
||
"0.40",
|
||
"0.025",
|
||
"0",
|
||
),
|
||
// StepFun 系列:CNY 按 1 USD ≈ 7.14 CNY 折算,保留两位小数。
|
||
// Step 5 Preview 官方输入 / 输出 / 缓存读取:7 / 20 / 0.70 元(缓存读取 2026-10-14
|
||
// 起由 0.35 元上调;此前已按量调用过的老用户仍按 0.35 元计,表里取现行挂牌价)。
|
||
(
|
||
"step-5-preview",
|
||
"Step 5 Preview",
|
||
"0.98",
|
||
"2.80",
|
||
"0.10",
|
||
"0",
|
||
),
|
||
(
|
||
"step-3.7-flash",
|
||
"Step 3.7 Flash",
|
||
"0.19",
|
||
"1.13",
|
||
"0.04",
|
||
"0",
|
||
),
|
||
(
|
||
"step-3.5-flash",
|
||
"Step 3.5 Flash",
|
||
"0.10",
|
||
"0.30",
|
||
"0.02",
|
||
"0",
|
||
),
|
||
(
|
||
"step-3.5-flash-2603",
|
||
"Step 3.5 Flash 2603",
|
||
"0.10",
|
||
"0.30",
|
||
"0.02",
|
||
"0",
|
||
),
|
||
// ====== 国产模型 (USD/1M tokens) ======
|
||
// Doubao (字节跳动)
|
||
// Seed 2.1 系列(2026-06 火山引擎官方 list 价,CNY 按 ~7.14 折算):
|
||
// pro 输入 6 元 / 输出 30 元 / 命中 1.2 元
|
||
// turbo 输入 3 元 / 输出 15 元 / 命中 0.6 元
|
||
// 「缓存存储 0.017 元/M/小时」是按时长计费的存储费,与本表 cache_creation(按 token 写入价)口径不同,置 0。
|
||
(
|
||
"doubao-seed-2-1-pro",
|
||
"Doubao Seed 2.1 Pro",
|
||
"0.84",
|
||
"4.2",
|
||
"0.17",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-1-turbo",
|
||
"Doubao Seed 2.1 Turbo",
|
||
"0.42",
|
||
"2.1",
|
||
"0.08",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-code",
|
||
"Doubao Seed Code",
|
||
"0.17",
|
||
"1.11",
|
||
"0.02",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-pro",
|
||
"Doubao Seed 2.0 Pro",
|
||
"0.47",
|
||
"2.37",
|
||
"0.09",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-code",
|
||
"Doubao Seed 2.0 Code",
|
||
"0.47",
|
||
"2.37",
|
||
"0.09",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-code-preview-latest",
|
||
"Doubao Seed 2.0 Code Preview",
|
||
"0.47",
|
||
"2.37",
|
||
"0.09",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-lite",
|
||
"Doubao Seed 2.0 Lite",
|
||
"0.08",
|
||
"0.50",
|
||
"0.017",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-mini",
|
||
"Doubao Seed 2.0 Mini",
|
||
"0.03",
|
||
"0.31",
|
||
"0.0056",
|
||
"0",
|
||
),
|
||
// DeepSeek 系列
|
||
(
|
||
"deepseek-v3.2",
|
||
"DeepSeek V3.2",
|
||
"0.28",
|
||
"0.42",
|
||
"0.028",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v3.1",
|
||
"DeepSeek V3.1",
|
||
"0.55",
|
||
"1.67",
|
||
"0.055",
|
||
"0",
|
||
),
|
||
("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
|
||
// ── DeepSeek V4 系列:2026-08-16 16:00 UTC 起改为峰谷双档计价 ──
|
||
// 官方价页(api-docs.deepseek.com/quick_start/pricing,中英一致)直接挂 USD,
|
||
// 不再需要 CNY 折算。高峰时段 = 北京时间 9:00-12:00 与 14:00-18:00
|
||
// (= UTC 01:00-04:00、06:00-10:00),共 7h/天;其余 17h 为空闲档。
|
||
//
|
||
// 🔴 本表每模型仅一行、无时段维度,**统一录高峰档**(Jason 2026-08-18 拍板):
|
||
// ① 官方措辞是「空闲价为高峰价的一半」,高峰档才是基准挂牌价;
|
||
// ② 高峰时段正是中文用户的工作时间,是 AI 编程主力时段。
|
||
// 代价=夜间/凌晨用量高估一倍。勿按「阶梯取低档」惯例改成空闲档。
|
||
//
|
||
// input=缓存未命中价,cache_read=缓存命中价;DeepSeek 不单收 cache write → 0。
|
||
//
|
||
// ── 2026-09-11:V4 Flash 退役,三个 id 全部由 DeepSeek-V4.1-Flash 承接 ──
|
||
// 官方价页原文:legacy names `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp`
|
||
// 仍被接受,但「the corresponding models have been retired」,请求由 V4.1-Flash 服务
|
||
// 并按 Flash 价计费 → 三者同价。V4.1 Flash 高峰档 0.3/1.2/0.006(空闲档 0.15/0.6/0.003
|
||
// 恰为一半;models.dev 录的正是空闲档,故审计 A 段会长期报这几行,属预期)。
|
||
// deepseek-flash 是官方当前唯一推荐名,必须单列:查价前缀兜底是 LIKE '{id}-%',
|
||
// 只命中更长的行,短 id 匹配不到 deepseek-v4-flash,缺行即静默按 0 计费。
|
||
//
|
||
// 🔴 deepseek-chat / deepseek-reasoner 停在 V4 Flash 高峰档不动(2026-09-11 复核):
|
||
// 官方文档站已全站搜不到这两个 id、models.dev 第一方条目也已删除 —— 无权威源可证
|
||
// 「跟随 V4.1 Flash 降价」或「已下线」任一方向,按无源不动原则保留旧值。
|
||
(
|
||
"deepseek-chat",
|
||
"DeepSeek Chat",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-reasoner",
|
||
"DeepSeek Reasoner",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-flash",
|
||
"DeepSeek V4.1 Flash",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
),
|
||
// 第三方上游(OpenCode Go、OpenRouter 的 deepseek/ 命名空间、Hermes 预设等)用带版本号的
|
||
// deepseek-v4.1-flash。点号版本既剥不成 deepseek-flash,前缀兜底也只命中更长的行,
|
||
// 不单列会显示「未定价」并按 0 计费
|
||
(
|
||
"deepseek-v4.1-flash",
|
||
"DeepSeek V4.1 Flash",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v4-flash",
|
||
"DeepSeek V4 Flash",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
),
|
||
// 部分上游(如阿里百炼)回传 4 位 MMDD 日期变体。查价的
|
||
// strip_model_date_suffix 只剥 ISO / 8 位 YYYYMMDD / 6 位 YYMMDD,
|
||
// 剥不到裸 id,前缀兜底也只匹配更长的行 —— 不补别名会静默按 0 计费
|
||
(
|
||
"deepseek-v4-flash-0731",
|
||
"DeepSeek V4 Flash",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
),
|
||
// 旧视觉实验名,官方定价页明示「仍被接受、由 V4.1-Flash 承接并按 Flash 价计费」。
|
||
// 官方安装脚本 ≤1.2.0 写过这个 id,存量供应商仍在用;前缀兜底匹配不到更短的
|
||
// deepseek-v4-flash,不单列会静默按 0 计费
|
||
(
|
||
"deepseek-v4-flash-vision-exp",
|
||
"DeepSeek V4 Flash Vision Exp",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
),
|
||
// V4 Pro 高峰档 1.32/3.96/0.044(CNY 9/27/0.3)。官方 2026-09-12 撤回了「09-14 起
|
||
// 路由到 V4.1 Flash」的公告,定价页注(2):9 月 14 日之后继续提供 V4 Pro API,
|
||
// 计费方式保持不变(2026-09-15 复核)。
|
||
// 🔴 勿按未生效的厂商公告提前改价:v3.20.3 曾因此发出 0.3/1.2/0.006 错价。
|
||
(
|
||
"deepseek-v4-pro",
|
||
"DeepSeek V4 Pro",
|
||
"1.32",
|
||
"3.96",
|
||
"0.044",
|
||
"0",
|
||
),
|
||
// Kimi (月之暗面)
|
||
(
|
||
"kimi-k2-thinking",
|
||
"Kimi K2 Thinking",
|
||
"0.55",
|
||
"2.20",
|
||
"0.10",
|
||
"0",
|
||
),
|
||
("kimi-k2-0905", "Kimi K2", "0.55", "2.20", "0.10", "0"),
|
||
(
|
||
"kimi-k2-turbo",
|
||
"Kimi K2 Turbo",
|
||
"1.11",
|
||
"8.06",
|
||
"0.14",
|
||
"0",
|
||
),
|
||
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
|
||
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
|
||
(
|
||
"kimi-k2.7-code",
|
||
"Kimi K2.7 Code",
|
||
"0.95",
|
||
"4.00",
|
||
"0.19",
|
||
"0",
|
||
),
|
||
// HighSpeed 加速档=本体 2 倍价(Kimi 官方一贯模式,同 K2 Turbo)
|
||
(
|
||
"kimi-k2.7-code-highspeed",
|
||
"Kimi K2.7 Code HighSpeed",
|
||
"1.90",
|
||
"8.00",
|
||
"0.38",
|
||
"0",
|
||
),
|
||
// 2026-10-19 官方价页:K3 缓存写入按 TTL 计费,默认 5 分钟档 $3(1 小时档 $6,取默认档)
|
||
("kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "3.00"),
|
||
// Kimi For Coding 套餐里 K3 的裸名(无 kimi- 前缀),同标准 list 价
|
||
("k3", "Kimi K3", "3.00", "15.00", "0.30", "3.00"),
|
||
// 腾讯混元 (Tencent Hunyuan)(官方 CNY 1/4/0.25 按 1 USD ≈ 7.14 折算;Hy3 阶梯计价取最低档)
|
||
("hunyuan-hy3", "Hunyuan Hy3", "0.14", "0.56", "0.035", "0"),
|
||
("hy3", "Hunyuan Hy3", "0.14", "0.56", "0.035", "0"),
|
||
// Hy4 preview:官方广州地域 CNY 6/18/0.3(1823/130055,2026-09-11 版)按 7.14 折算,无阶梯
|
||
(
|
||
"hy4-preview",
|
||
"Hunyuan Hy4 Preview",
|
||
"0.84",
|
||
"2.52",
|
||
"0.042",
|
||
"0",
|
||
),
|
||
// MiniMax 系列
|
||
// 2026-09-06 审计:官方按量价页(platform.minimax.io/docs/guides/pricing-paygo)
|
||
// M2 / M2.1 / M2.5 均为 0.3/1.2/0.03/0.375,models.dev 一致;旧值 0.27/0.95 与 0.15 为早期误录。
|
||
(
|
||
"minimax-m2.1",
|
||
"MiniMax M2.1",
|
||
"0.30",
|
||
"1.20",
|
||
"0.03",
|
||
"0.375",
|
||
),
|
||
(
|
||
"minimax-m2.1-lightning",
|
||
"MiniMax M2.1 Lightning",
|
||
"0.27",
|
||
"2.33",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
("minimax-m2", "MiniMax M2", "0.30", "1.20", "0.03", "0.375"),
|
||
(
|
||
"minimax-m2.5",
|
||
"MiniMax M2.5",
|
||
"0.30",
|
||
"1.20",
|
||
"0.03",
|
||
"0.375",
|
||
),
|
||
(
|
||
"minimax-m2.5-lightning",
|
||
"MiniMax M2.5 Lightning",
|
||
"0.30",
|
||
"2.40",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
(
|
||
"minimax-m2.7",
|
||
"MiniMax M2.7",
|
||
"0.30",
|
||
"1.20",
|
||
"0.06",
|
||
"0.375",
|
||
),
|
||
(
|
||
"minimax-m2.7-highspeed",
|
||
"MiniMax M2.7 Highspeed",
|
||
"0.60",
|
||
"2.40",
|
||
"0.06",
|
||
"0.375",
|
||
),
|
||
("minimax-m3", "MiniMax M3", "0.30", "1.20", "0.06", "0"),
|
||
(
|
||
"minimax-m3.1-flash",
|
||
"MiniMax M3.1 Flash",
|
||
"0.15",
|
||
"0.60",
|
||
"0.015",
|
||
"0",
|
||
),
|
||
// GLM (智谱)
|
||
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
|
||
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
|
||
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
||
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
||
("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"),
|
||
("glm-5.3", "GLM-5.3", "1.4", "4.4", "0.26", "0"),
|
||
(
|
||
"glm-5.3-flash",
|
||
"GLM-5.3-Flash",
|
||
"0.15",
|
||
"0.50",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
(
|
||
"glm-5.3-flashx",
|
||
"GLM-5.3-FlashX",
|
||
"0.37",
|
||
"1.25",
|
||
"0.075",
|
||
"0",
|
||
),
|
||
("glm-5-turbo", "GLM-5-Turbo", "1.2", "4", "0.24", "0"),
|
||
("glm-5v-turbo", "GLM-5V-Turbo", "1.2", "4", "0.24", "0"),
|
||
// MiMo (小米)
|
||
(
|
||
"mimo-v2-flash",
|
||
"MiMo V2 Flash",
|
||
"0.09",
|
||
"0.29",
|
||
"0.009",
|
||
"0",
|
||
),
|
||
("mimo-v2-pro", "MiMo V2 Pro", "0.435", "0.87", "0.0036", "0"),
|
||
("mimo-v2.5", "MiMo V2.5", "0.14", "0.28", "0.0028", "0"),
|
||
(
|
||
"mimo-v2.5-pro",
|
||
"MiMo V2.5 Pro",
|
||
"0.435",
|
||
"0.87",
|
||
"0.0036",
|
||
"0",
|
||
),
|
||
// Qwen 系列 (阿里巴巴)
|
||
("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"),
|
||
// 2026-09-06:阿里国际站价页 0.15/0.47 全区间(0<Token≤1M)平价、无阶梯;
|
||
// 缓存两列官方只注明"非常规比例"未给数字,取 models.dev(与 qwen3.8-max 同口径)
|
||
(
|
||
"qwen3.8-flash",
|
||
"Qwen3.8 Flash",
|
||
"0.15",
|
||
"0.47",
|
||
"0.016",
|
||
"0.20",
|
||
),
|
||
// 2026-09-15:开放权重两款,取阿里国际站(新加坡)模型页单价,无阶梯;缓存两列为
|
||
// 隐式缓存命中价与显式缓存创建价,与 qwen3.8-max 同口径
|
||
(
|
||
"qwen3.8-2.4t-a95b",
|
||
"Qwen3.8 2.4T A95B",
|
||
"2",
|
||
"6",
|
||
"0.25",
|
||
"2.50",
|
||
),
|
||
("qwen3.8-27b", "Qwen3.8 27B", "0.50", "3", "0.10", "0.625"),
|
||
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
||
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
||
// 2026-10-19:阿里国际站(新加坡)价页。qwen3.7-flash 阶梯计价取最低档(≤32K),
|
||
// 缓存两列按 qwen3.8 同口径(隐式命中 20%、显式创建 125%);qwen3.6-27b 无阶梯、
|
||
// 价页未列缓存价;qwen3.8-omni-flash 取文本价,价页只列缓存命中价
|
||
(
|
||
"qwen3.7-flash",
|
||
"Qwen3.7 Flash",
|
||
"0.03",
|
||
"0.13",
|
||
"0.006",
|
||
"0.0375",
|
||
),
|
||
("qwen3.6-27b", "Qwen3.6 27B", "0.60", "3.60", "0", "0"),
|
||
(
|
||
"qwen3.8-omni-flash",
|
||
"Qwen3.8 Omni Flash",
|
||
"0.15",
|
||
"0.47",
|
||
"0.016",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3.6-plus",
|
||
"Qwen3.6 Plus",
|
||
"0.325",
|
||
"1.95",
|
||
"0.065",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3.6-flash",
|
||
"Qwen3.6 Flash",
|
||
"0.1875",
|
||
"1.125",
|
||
"0.0375",
|
||
"0",
|
||
),
|
||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
|
||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||
(
|
||
"qwen3-235b-a22b",
|
||
"Qwen3 235B-A22B",
|
||
"0.70",
|
||
"8.40",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3-coder-plus",
|
||
"Qwen3 Coder Plus",
|
||
"0.65",
|
||
"3.25",
|
||
"0.13",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3-coder-480b",
|
||
"Qwen3 Coder 480B",
|
||
"0.65",
|
||
"3.25",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3-coder-480b-a35b-instruct",
|
||
"Qwen3 Coder 480B-A35B Instruct",
|
||
"0.65",
|
||
"3.25",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3-coder-flash",
|
||
"Qwen3 Coder Flash",
|
||
"0.195",
|
||
"0.975",
|
||
"0.039",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3-coder-next",
|
||
"Qwen3 Coder Next",
|
||
"0.12",
|
||
"0.75",
|
||
"0",
|
||
"0",
|
||
),
|
||
("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
|
||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||
// Grok 系列 (xAI)
|
||
// 4.5/4.6/4.7 均为分档计价:prompt ≥200K 时单价翻倍(4/12,cached 亦翻倍)。
|
||
// 本表无档位列,统一取基础档(<200K),与其它分档厂商口径一致
|
||
("grok-4.7", "Grok 4.7", "2", "6", "0.50", "0"),
|
||
("grok-4.6", "Grok 4.6", "2", "6", "0.50", "0"),
|
||
("grok-4.5", "Grok 4.5", "2", "6", "0.30", "0"),
|
||
// Grok CLI 官方 OAuth 态 modelUsage 上报的内部别名。定价由
|
||
// costUsdTicks(1 tick = 1e-10 USD)双轮实测反推:input/output 与
|
||
// grok-4.5 同为 2/6,cache read 同为 0.30
|
||
("grok-4.5-build", "Grok 4.5 Build", "2", "6", "0.30", "0"),
|
||
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
||
(
|
||
"grok-4.20-0309-reasoning",
|
||
"Grok 4.20 Reasoning",
|
||
"1.25",
|
||
"2.50",
|
||
"0.20",
|
||
"0",
|
||
),
|
||
(
|
||
"grok-4.20-0309-non-reasoning",
|
||
"Grok 4.20",
|
||
"1.25",
|
||
"2.50",
|
||
"0.20",
|
||
"0",
|
||
),
|
||
(
|
||
"grok-4-1-fast-reasoning",
|
||
"Grok 4.1 Fast Reasoning",
|
||
"0.20",
|
||
"0.50",
|
||
"0.05",
|
||
"0",
|
||
),
|
||
(
|
||
"grok-4-1-fast-non-reasoning",
|
||
"Grok 4.1 Fast",
|
||
"0.20",
|
||
"0.50",
|
||
"0.05",
|
||
"0",
|
||
),
|
||
("grok-4", "Grok 4", "3", "15", "0.75", "0"),
|
||
(
|
||
"grok-code-fast-1",
|
||
"Grok Build 0.1 (Code Fast Alias)",
|
||
"1",
|
||
"2",
|
||
"0.20",
|
||
"0",
|
||
),
|
||
("grok-build-0.1", "Grok Build 0.1", "1", "2", "0.20", "0"),
|
||
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
||
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
||
// Mistral 系列
|
||
(
|
||
"mistral-medium-3.5",
|
||
"Mistral Medium 3.5",
|
||
"1.50",
|
||
"7.50",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"mistral-small-4",
|
||
"Mistral Small 4",
|
||
"0.10",
|
||
"0.30",
|
||
"0.01",
|
||
"0",
|
||
),
|
||
(
|
||
"devstral-small-2-2512",
|
||
"Devstral Small 2",
|
||
"0.10",
|
||
"0.30",
|
||
"0.01",
|
||
"0",
|
||
),
|
||
(
|
||
"magistral-small",
|
||
"Magistral Small",
|
||
"0.50",
|
||
"1.50",
|
||
"0",
|
||
"0",
|
||
),
|
||
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
||
(
|
||
"devstral-small-1.1",
|
||
"Devstral Small 1.1",
|
||
"0.07",
|
||
"0.28",
|
||
"0.01",
|
||
"0",
|
||
),
|
||
("devstral-2-2512", "Devstral 2", "0.40", "2", "0.04", "0"),
|
||
(
|
||
"devstral-medium",
|
||
"Devstral Medium",
|
||
"0.40",
|
||
"2",
|
||
"0.04",
|
||
"0",
|
||
),
|
||
(
|
||
"mistral-large-3-2512",
|
||
"Mistral Large 3",
|
||
"0.50",
|
||
"1.50",
|
||
"0.05",
|
||
"0",
|
||
),
|
||
(
|
||
"mistral-medium-3.1",
|
||
"Mistral Medium 3.1",
|
||
"0.40",
|
||
"2",
|
||
"0.04",
|
||
"0",
|
||
),
|
||
(
|
||
"mistral-small-3.2-24b",
|
||
"Mistral Small 3.2",
|
||
"0.075",
|
||
"0.20",
|
||
"0.01",
|
||
"0",
|
||
),
|
||
("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
|
||
// Cohere 系列
|
||
("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
|
||
(
|
||
"command-r-plus",
|
||
"Cohere Command R+",
|
||
"2.50",
|
||
"10",
|
||
"0",
|
||
"0",
|
||
),
|
||
("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
|
||
// OpenAI 补充
|
||
("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
|
||
("o3-mini", "OpenAI o3-mini", "1.10", "4.40", "0.55", "0"),
|
||
("o1", "OpenAI o1", "15", "60", "7.50", "0"),
|
||
("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
|
||
("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
|
||
("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
|
||
("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
|
||
// MiMo 2.6:2026-09-23 核对官方标准价格,单位 USD / 百万 tokens。
|
||
(
|
||
"mimo-v2.6-pro",
|
||
"MiMo V2.6 Pro",
|
||
"0.435",
|
||
"0.87",
|
||
"0.0036",
|
||
"0",
|
||
),
|
||
(
|
||
"mimo-v2.6-flash",
|
||
"MiMo V2.6 Flash",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
),
|
||
(
|
||
"mimo-v2.6-pro-ultraspeed",
|
||
"MiMo V2.6 Pro UltraSpeed",
|
||
"4.35",
|
||
"8.7",
|
||
"0.036",
|
||
"0",
|
||
),
|
||
];
|
||
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"INSERT OR IGNORE INTO model_pricing (
|
||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||
)
|
||
.map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
|
||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||
stmt.execute(rusqlite::params![
|
||
model_id,
|
||
display_name,
|
||
input,
|
||
output,
|
||
cache_read,
|
||
cache_creation
|
||
])
|
||
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
||
}
|
||
|
||
log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
|
||
Ok(())
|
||
}
|
||
|
||
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||
let pricing_fixes = [
|
||
// 2026-10-19 models.dev 审计核价:Kimi K3 官方价页列有缓存写入(默认 5 分钟档 $3),
|
||
// 早先按 0 录入;Step 5 Preview 缓存读取 2026-10-14 起由 0.35 元上调至 0.70 元
|
||
(
|
||
"kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "3.00", "3.00", "15.00", "0.30", "0",
|
||
),
|
||
(
|
||
"k3", "Kimi K3", "3.00", "15.00", "0.30", "3.00", "3.00", "15.00", "0.30", "0",
|
||
),
|
||
(
|
||
"step-5-preview",
|
||
"Step 5 Preview",
|
||
"0.98",
|
||
"2.80",
|
||
"0.10",
|
||
"0",
|
||
"0.98",
|
||
"2.80",
|
||
"0.05",
|
||
"0",
|
||
),
|
||
// 2026-09-02 官方定价页确认 Sonnet 5 $2/$10 介绍价转为正式价、原定 09-01 涨至
|
||
// $3/$15 取消:早先按 list 价 seed 的行改回正式价(用户手改过的行不匹配旧值,不动)
|
||
(
|
||
"claude-sonnet-5",
|
||
"Claude Sonnet 5",
|
||
"2",
|
||
"10",
|
||
"0.20",
|
||
"2.50",
|
||
"3",
|
||
"15",
|
||
"0.30",
|
||
"3.75",
|
||
),
|
||
// 2026-08-13 models.dev 审计核价:grok-4.5 的 cached input 官方挂牌为 0.30
|
||
// (docs.x.ai 现行价表),与 grok-4.5-build 的实测计费一致;早先按 0.50
|
||
// 录入的行在此校正。注意 0.50 是 grok-4.6 的 cached 价,勿两者互串
|
||
(
|
||
"grok-4.5", "Grok 4.5", "2", "6", "0.30", "0", "2", "6", "0.50", "0",
|
||
),
|
||
// 2026-07-30 OpenAI GPT-5.6 降价:luna -80%、terra -20%(sol 不变)。
|
||
// 每档两条守卫:主守卫匹配 ≥v3.19(已跑过 07-12 cache_write 修正),
|
||
// 0 态守卫匹配 <v3.19 直升用户(cache_write 仍为旧 seed 的 0)
|
||
(
|
||
"gpt-5.6-luna",
|
||
"GPT-5.6 Luna",
|
||
"0.20",
|
||
"1.20",
|
||
"0.02",
|
||
"0.25",
|
||
"1",
|
||
"6",
|
||
"0.10",
|
||
"1.25",
|
||
),
|
||
(
|
||
"gpt-5.6-luna",
|
||
"GPT-5.6 Luna",
|
||
"0.20",
|
||
"1.20",
|
||
"0.02",
|
||
"0.25",
|
||
"1",
|
||
"6",
|
||
"0.10",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.6-terra",
|
||
"GPT-5.6 Terra",
|
||
"2",
|
||
"12",
|
||
"0.20",
|
||
"2.50",
|
||
"2.50",
|
||
"15",
|
||
"0.25",
|
||
"3.125",
|
||
),
|
||
(
|
||
"gpt-5.6-terra",
|
||
"GPT-5.6 Terra",
|
||
"2",
|
||
"12",
|
||
"0.20",
|
||
"2.50",
|
||
"2.50",
|
||
"15",
|
||
"0.25",
|
||
"0",
|
||
),
|
||
// 2026-07-31 models.dev 审计核价:DeepSeek V4 发布后 chat/reasoner 降为 V4 Flash
|
||
// 别名价;MiniMax M3 官方 standard 档 0.3/1.2(旧值疑似录了加速档)
|
||
(
|
||
"deepseek-chat",
|
||
"DeepSeek Chat",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
"0.27",
|
||
"1.10",
|
||
"0.07",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-reasoner",
|
||
"DeepSeek Reasoner",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
"0.55",
|
||
"2.19",
|
||
"0.14",
|
||
"0",
|
||
),
|
||
(
|
||
"minimax-m3",
|
||
"MiniMax M3",
|
||
"0.30",
|
||
"1.20",
|
||
"0.06",
|
||
"0",
|
||
"0.60",
|
||
"2.40",
|
||
"0.12",
|
||
"0",
|
||
),
|
||
// 2026-07-12 GPT-5.6 家族 cache write=1.25× 输入价(OpenAI 5.6 起的新规),
|
||
// 修正早期 seed 的 0 值;只匹配未被用户改过的行
|
||
(
|
||
"gpt-5.6-sol",
|
||
"GPT-5.6 Sol",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.6-terra",
|
||
"GPT-5.6 Terra",
|
||
"2.50",
|
||
"15",
|
||
"0.25",
|
||
"3.125",
|
||
"2.50",
|
||
"15",
|
||
"0.25",
|
||
"0",
|
||
),
|
||
(
|
||
"gpt-5.6-luna",
|
||
"GPT-5.6 Luna",
|
||
"1",
|
||
"6",
|
||
"0.10",
|
||
"1.25",
|
||
"1",
|
||
"6",
|
||
"0.10",
|
||
"0",
|
||
),
|
||
// 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算)
|
||
// GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致)
|
||
(
|
||
"glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0", "0.39", "1.75", "0.04", "0",
|
||
),
|
||
(
|
||
"glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0", "0.28", "1.11", "0.03", "0",
|
||
),
|
||
// Grok 4.20:xAI 已降价 2/6 → 1.25/2.50
|
||
(
|
||
"grok-4.20-0309-reasoning",
|
||
"Grok 4.20 Reasoning",
|
||
"1.25",
|
||
"2.50",
|
||
"0.20",
|
||
"0",
|
||
"2",
|
||
"6",
|
||
"0.20",
|
||
"0",
|
||
),
|
||
(
|
||
"grok-4.20-0309-non-reasoning",
|
||
"Grok 4.20",
|
||
"1.25",
|
||
"2.50",
|
||
"0.20",
|
||
"0",
|
||
"2",
|
||
"6",
|
||
"0.20",
|
||
"0",
|
||
),
|
||
// Kimi K2.5 官方 output 3.00
|
||
(
|
||
"kimi-k2.5",
|
||
"Kimi K2.5",
|
||
"0.60",
|
||
"3.00",
|
||
"0.10",
|
||
"0",
|
||
"0.60",
|
||
"2.50",
|
||
"0.10",
|
||
"0",
|
||
),
|
||
// MiniMax M2.5 input 0.15
|
||
(
|
||
"minimax-m2.5",
|
||
"MiniMax M2.5",
|
||
"0.15",
|
||
"0.95",
|
||
"0.03",
|
||
"0",
|
||
"0.12",
|
||
"0.95",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
// Mistral Devstral 2 output 0.90 → 2(与同表 devstral-medium 一致)
|
||
(
|
||
"devstral-2-2512",
|
||
"Devstral 2",
|
||
"0.40",
|
||
"2",
|
||
"0.04",
|
||
"0",
|
||
"0.40",
|
||
"0.90",
|
||
"0.04",
|
||
"0",
|
||
),
|
||
// Doubao Seed 2.0:lite 旧价贵 3-4 倍 + 全系补 cache 命中价
|
||
(
|
||
"doubao-seed-2-0-lite",
|
||
"Doubao Seed 2.0 Lite",
|
||
"0.08",
|
||
"0.50",
|
||
"0.017",
|
||
"0",
|
||
"0.25",
|
||
"2",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-pro",
|
||
"Doubao Seed 2.0 Pro",
|
||
"0.47",
|
||
"2.37",
|
||
"0.09",
|
||
"0",
|
||
"0.47",
|
||
"2.37",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-code",
|
||
"Doubao Seed 2.0 Code",
|
||
"0.47",
|
||
"2.37",
|
||
"0.09",
|
||
"0",
|
||
"0.47",
|
||
"2.37",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-code-preview-latest",
|
||
"Doubao Seed 2.0 Code Preview",
|
||
"0.47",
|
||
"2.37",
|
||
"0.09",
|
||
"0",
|
||
"0.47",
|
||
"2.37",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"doubao-seed-2-0-mini",
|
||
"Doubao Seed 2.0 Mini",
|
||
"0.03",
|
||
"0.31",
|
||
"0.0056",
|
||
"0",
|
||
"0.03",
|
||
"0.31",
|
||
"0",
|
||
"0",
|
||
),
|
||
// MiMo:5/27 永久降价,旧值是旧价
|
||
(
|
||
"mimo-v2-pro",
|
||
"MiMo V2 Pro",
|
||
"0.435",
|
||
"0.87",
|
||
"0.0036",
|
||
"0",
|
||
"1",
|
||
"3",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"mimo-v2.5",
|
||
"MiMo V2.5",
|
||
"0.14",
|
||
"0.29",
|
||
"0.0028",
|
||
"0",
|
||
"0.09",
|
||
"0.29",
|
||
"0.009",
|
||
"0",
|
||
),
|
||
(
|
||
"mimo-v2.5-pro",
|
||
"MiMo V2.5 Pro",
|
||
"0.435",
|
||
"0.87",
|
||
"0.0036",
|
||
"0",
|
||
"1",
|
||
"3",
|
||
"0",
|
||
"0",
|
||
),
|
||
// Qwen:官方"隐式缓存 = 输入 20%"补 cache 命中价
|
||
(
|
||
"qwen3.6-plus",
|
||
"Qwen3.6 Plus",
|
||
"0.325",
|
||
"1.95",
|
||
"0.065",
|
||
"0",
|
||
"0.325",
|
||
"1.95",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3.5-plus",
|
||
"Qwen3.5 Plus",
|
||
"0.26",
|
||
"1.56",
|
||
"0.052",
|
||
"0",
|
||
"0.26",
|
||
"1.56",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3-coder-plus",
|
||
"Qwen3 Coder Plus",
|
||
"0.65",
|
||
"3.25",
|
||
"0.13",
|
||
"0",
|
||
"0.65",
|
||
"3.25",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"qwen3-coder-flash",
|
||
"Qwen3 Coder Flash",
|
||
"0.195",
|
||
"0.975",
|
||
"0.039",
|
||
"0",
|
||
"0.195",
|
||
"0.975",
|
||
"0",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v4-flash",
|
||
"DeepSeek V4 Flash",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
"0.14",
|
||
"0.28",
|
||
"0.028",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v4-pro",
|
||
"DeepSeek V4 Pro",
|
||
"0.435",
|
||
"0.87",
|
||
"0.003625",
|
||
"0",
|
||
"1.68",
|
||
"3.36",
|
||
"0.14",
|
||
"0",
|
||
),
|
||
(
|
||
"glm-5", "GLM-5", "1", "3.2", "0.2", "0", "0.72", "2.30", "0", "0",
|
||
),
|
||
(
|
||
"glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0", "0.95", "3.15", "0", "0",
|
||
),
|
||
(
|
||
"grok-code-fast-1",
|
||
"Grok Build 0.1 (Code Fast Alias)",
|
||
"1",
|
||
"2",
|
||
"0.20",
|
||
"0",
|
||
"0.20",
|
||
"1.50",
|
||
"0.02",
|
||
"0",
|
||
),
|
||
// 2026-08-16 16:00 UTC DeepSeek V4 全系改峰谷双档计价(本表统一录高峰档,
|
||
// 理由见 seed_model_pricing 里 DeepSeek V4 段的注释)。涨幅很大:
|
||
// flash 0.14/0.28/0.0028 → 0.44/1.32/0.014;pro 0.435/0.87/0.003625 → 1.32/3.96/0.044。
|
||
//
|
||
// 🔴 这五条必须留在数组末尾:上面 2026-07-31 的 chat/reasoner 条目与
|
||
// 2026-07 的 v4-flash(cache_read 0.028→0.0028) / v4-pro(1.68/3.36→0.435/0.87)
|
||
// 条目会先把各种历史形态收敛到同一个旧值,这里才能单守卫命中。
|
||
// 若把本组挪到它们之前,老库会停在中间价位不再前进。
|
||
(
|
||
"deepseek-chat",
|
||
"DeepSeek Chat",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-reasoner",
|
||
"DeepSeek Reasoner",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v4-flash",
|
||
"DeepSeek V4 Flash",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v4-flash-0731",
|
||
"DeepSeek V4 Flash",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v4-pro",
|
||
"DeepSeek V4 Pro",
|
||
"1.32",
|
||
"3.96",
|
||
"0.044",
|
||
"0",
|
||
"0.435",
|
||
"0.87",
|
||
"0.003625",
|
||
"0",
|
||
),
|
||
// 2026-09-06 审计。以下条目须排在上方所有旧条目之后(链式守卫,顺序由
|
||
// tests.rs::model_pricing_seed_repairs_known_outdated_builtin_prices 锁住):
|
||
// - gpt-5.6-sol:<v3.19 老库先经 07-12 条目把 cache_write 0 补成 6.25,再由本条降到促销价
|
||
// - minimax-m2.5:先经 0.12→0.15 条目,再由本条到 0.30
|
||
// Google 3.6 Flash 改介绍价 0.75/3.75/0.075(至 2026-12-31,挂牌 1.50/7.50/0.15)
|
||
(
|
||
"gemini-3.6-flash",
|
||
"Gemini 3.6 Flash",
|
||
"0.75",
|
||
"3.75",
|
||
"0.075",
|
||
"0",
|
||
"1.50",
|
||
"7.50",
|
||
"0.15",
|
||
"0",
|
||
),
|
||
// OpenAI GPT-5.6 Sol 促销价 4/20/0.40/5(至少到 2026-11-21);裸名与 effort 后缀行同步
|
||
(
|
||
"gpt-5.6-sol",
|
||
"GPT-5.6 Sol",
|
||
"4",
|
||
"20",
|
||
"0.40",
|
||
"5",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"gpt-5.6",
|
||
"GPT-5.6 Sol",
|
||
"4",
|
||
"20",
|
||
"0.40",
|
||
"5",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"gpt-5.6-low",
|
||
"GPT-5.6 Sol",
|
||
"4",
|
||
"20",
|
||
"0.40",
|
||
"5",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"gpt-5.6-medium",
|
||
"GPT-5.6 Sol",
|
||
"4",
|
||
"20",
|
||
"0.40",
|
||
"5",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"gpt-5.6-high",
|
||
"GPT-5.6 Sol",
|
||
"4",
|
||
"20",
|
||
"0.40",
|
||
"5",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"gpt-5.6-xhigh",
|
||
"GPT-5.6 Sol",
|
||
"4",
|
||
"20",
|
||
"0.40",
|
||
"5",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
(
|
||
"gpt-5.6-minimal",
|
||
"GPT-5.6 Sol",
|
||
"4",
|
||
"20",
|
||
"0.40",
|
||
"5",
|
||
"5",
|
||
"30",
|
||
"0.50",
|
||
"6.25",
|
||
),
|
||
// MiniMax 官方按量价:M2 / M2.1 / M2.5 = 0.3/1.2/0.03/0.375
|
||
(
|
||
"minimax-m2",
|
||
"MiniMax M2",
|
||
"0.30",
|
||
"1.20",
|
||
"0.03",
|
||
"0.375",
|
||
"0.27",
|
||
"0.95",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
(
|
||
"minimax-m2.1",
|
||
"MiniMax M2.1",
|
||
"0.30",
|
||
"1.20",
|
||
"0.03",
|
||
"0.375",
|
||
"0.27",
|
||
"0.95",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
(
|
||
"minimax-m2.5",
|
||
"MiniMax M2.5",
|
||
"0.30",
|
||
"1.20",
|
||
"0.03",
|
||
"0.375",
|
||
"0.15",
|
||
"0.95",
|
||
"0.03",
|
||
"0",
|
||
),
|
||
// 2026-09-11 审计:DeepSeek V4 Flash 退役,打到 deepseek-v4-flash / -0731 的
|
||
// 请求已由 V4.1-Flash 承接并按 Flash 价计费(官方定价页 quick_start/pricing),
|
||
// 高峰档 0.44/1.32/0.014 → 0.3/1.2/0.006。
|
||
//
|
||
// 🔴 必须排在上方 2026-08-16 峰谷调价五条之后:老库要先被那一组推到
|
||
// 0.44/1.32/0.014,本组的守卫才能命中;挪到其前老库会停在 0.44 不再前进。
|
||
// deepseek-chat / deepseek-reasoner 刻意不在本组 —— 官方已全面下架、无权威源
|
||
// 可证其跟随降价,见 seed_model_pricing 的 DeepSeek V4 段注释。
|
||
(
|
||
"deepseek-v4-flash",
|
||
"DeepSeek V4 Flash",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
),
|
||
(
|
||
"deepseek-v4-flash-0731",
|
||
"DeepSeek V4 Flash",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
"0.44",
|
||
"1.32",
|
||
"0.014",
|
||
"0",
|
||
),
|
||
// 2026-09-15:撤销 09-11 提前执行的 V4 Pro → Flash 档回调(官方 09-12 撤回迁移公告,
|
||
// V4 Pro 继续按原价计费)。v3.20.3 已把老库推到 0.3/1.2/0.006,本条修回高峰档。
|
||
// 🔴 原 1.32→0.3 条目必须删除而非保留:两条并存会让每次启动都来回改写。
|
||
(
|
||
"deepseek-v4-pro",
|
||
"DeepSeek V4 Pro",
|
||
"1.32",
|
||
"3.96",
|
||
"0.044",
|
||
"0",
|
||
"0.3",
|
||
"1.2",
|
||
"0.006",
|
||
"0",
|
||
),
|
||
// 2026-09-23 核对标准价格,仅修正仍匹配旧内置值的记录。
|
||
(
|
||
"mimo-v2.5",
|
||
"MiMo V2.5",
|
||
"0.14",
|
||
"0.28",
|
||
"0.0028",
|
||
"0",
|
||
"0.14",
|
||
"0.29",
|
||
"0.0028",
|
||
"0",
|
||
),
|
||
(
|
||
"o3-mini",
|
||
"OpenAI o3-mini",
|
||
"1.10",
|
||
"4.40",
|
||
"0.55",
|
||
"0",
|
||
"0.55",
|
||
"2.20",
|
||
"0.55",
|
||
"0",
|
||
),
|
||
];
|
||
|
||
for (
|
||
model_id,
|
||
display_name,
|
||
input,
|
||
output,
|
||
cache_read,
|
||
cache_creation,
|
||
old_input,
|
||
old_output,
|
||
old_cache_read,
|
||
old_cache_creation,
|
||
) in pricing_fixes
|
||
{
|
||
conn.execute(
|
||
"UPDATE model_pricing SET
|
||
display_name = ?2,
|
||
input_cost_per_million = ?3,
|
||
output_cost_per_million = ?4,
|
||
cache_read_cost_per_million = ?5,
|
||
cache_creation_cost_per_million = ?6
|
||
WHERE model_id = ?1
|
||
AND input_cost_per_million = ?7
|
||
AND output_cost_per_million = ?8
|
||
AND cache_read_cost_per_million = ?9
|
||
AND cache_creation_cost_per_million = ?10",
|
||
rusqlite::params![
|
||
model_id,
|
||
display_name,
|
||
input,
|
||
output,
|
||
cache_read,
|
||
cache_creation,
|
||
old_input,
|
||
old_output,
|
||
old_cache_read,
|
||
old_cache_creation
|
||
],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("修复模型 {model_id} 定价失败: {e}")))?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 确保模型定价表具备默认数据
|
||
pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
Self::ensure_model_pricing_seeded_on_conn(&conn)
|
||
}
|
||
|
||
pub(crate) fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型;仅修复仍等于旧内置值的定价。
|
||
Self::seed_model_pricing(conn)?;
|
||
Self::repair_current_model_pricing(conn)
|
||
}
|
||
|
||
// --- 辅助方法 ---
|
||
|
||
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
|
||
conn.query_row("PRAGMA user_version;", [], |row| row.get(0))
|
||
.map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}")))
|
||
}
|
||
|
||
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
|
||
if version < 0 {
|
||
return Err(AppError::Database("user_version 不能为负数".to_string()));
|
||
}
|
||
let sql = format!("PRAGMA user_version = {version};");
|
||
conn.execute(&sql, [])
|
||
.map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?;
|
||
Ok(())
|
||
}
|
||
|
||
fn create_request_logs_usage_indexes_if_supported(conn: &Connection) -> Result<(), AppError> {
|
||
if !Self::table_exists(conn, "proxy_request_logs")? {
|
||
return Ok(());
|
||
}
|
||
|
||
let has_app_type = Self::has_column(conn, "proxy_request_logs", "app_type")?;
|
||
let has_created_at = Self::has_column(conn, "proxy_request_logs", "created_at")?;
|
||
if has_app_type && has_created_at {
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_request_logs_app_created_at
|
||
ON proxy_request_logs(app_type, created_at DESC)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建使用量应用时间索引失败: {e}")))?;
|
||
}
|
||
|
||
let required_columns = [
|
||
"app_type",
|
||
"data_source",
|
||
"input_tokens",
|
||
"output_tokens",
|
||
"cache_read_tokens",
|
||
"created_at",
|
||
"cache_creation_tokens",
|
||
];
|
||
for column in required_columns {
|
||
if !Self::has_column(conn, "proxy_request_logs", column)? {
|
||
return Ok(());
|
||
}
|
||
}
|
||
|
||
conn.execute("DROP INDEX IF EXISTS idx_request_logs_dedup_lookup", [])
|
||
.map_err(|e| AppError::Database(format!("删除旧使用量去重索引失败: {e}")))?;
|
||
|
||
// 查询层为了兼容历史 NULL data_source 行,会使用
|
||
// COALESCE(data_source, 'proxy')。普通 data_source 索引无法匹配该表达式,
|
||
// 会让跨源去重子查询退化成大量扫描;表达式索引让 SQLite 能按同一表达式查找。
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_request_logs_dedup_lookup_expr
|
||
ON proxy_request_logs(app_type, COALESCE(data_source, 'proxy'), input_tokens,
|
||
output_tokens, cache_read_tokens, created_at,
|
||
cache_creation_tokens)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("创建使用量去重表达式索引失败: {e}")))?;
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
|
||
if s.is_empty() {
|
||
return Err(AppError::Database(format!("{kind} 不能为空")));
|
||
}
|
||
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||
return Err(AppError::Database(format!(
|
||
"非法{kind}: {s},仅允许字母、数字和下划线"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
|
||
Self::validate_identifier(table, "表名")?;
|
||
|
||
let mut stmt = conn
|
||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||
.map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
|
||
let mut rows = stmt
|
||
.query([])
|
||
.map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
|
||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||
let name: String = row
|
||
.get(0)
|
||
.map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
|
||
if name.eq_ignore_ascii_case(table) {
|
||
return Ok(true);
|
||
}
|
||
}
|
||
Ok(false)
|
||
}
|
||
|
||
pub(crate) fn has_column(
|
||
conn: &Connection,
|
||
table: &str,
|
||
column: &str,
|
||
) -> Result<bool, AppError> {
|
||
Self::validate_identifier(table, "表名")?;
|
||
Self::validate_identifier(column, "列名")?;
|
||
|
||
let sql = format!("PRAGMA table_info(\"{table}\");");
|
||
let mut stmt = conn
|
||
.prepare(&sql)
|
||
.map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?;
|
||
let mut rows = stmt
|
||
.query([])
|
||
.map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?;
|
||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||
let name: String = row
|
||
.get(1)
|
||
.map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?;
|
||
if name.eq_ignore_ascii_case(column) {
|
||
return Ok(true);
|
||
}
|
||
}
|
||
Ok(false)
|
||
}
|
||
|
||
fn add_column_if_missing(
|
||
conn: &Connection,
|
||
table: &str,
|
||
column: &str,
|
||
definition: &str,
|
||
) -> Result<bool, AppError> {
|
||
Self::validate_identifier(table, "表名")?;
|
||
Self::validate_identifier(column, "列名")?;
|
||
|
||
if !Self::table_exists(conn, table)? {
|
||
return Err(AppError::Database(format!(
|
||
"表 {table} 不存在,无法添加列 {column}"
|
||
)));
|
||
}
|
||
if Self::has_column(conn, table, column)? {
|
||
return Ok(false);
|
||
}
|
||
|
||
let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
|
||
conn.execute(&sql, [])
|
||
.map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
|
||
log::info!("已为表 {table} 添加缺失列 {column}");
|
||
Ok(true)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn migrate_v12_to_v13_adds_input_token_semantics_columns() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
conn.execute(
|
||
"CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY)",
|
||
[],
|
||
)?;
|
||
conn.execute(
|
||
"CREATE TABLE usage_daily_rollups (date TEXT PRIMARY KEY)",
|
||
[],
|
||
)?;
|
||
Database::set_user_version(&conn, 12)?;
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
assert!(Database::has_column(
|
||
&conn,
|
||
"proxy_request_logs",
|
||
"input_token_semantics"
|
||
)?);
|
||
assert!(Database::has_column(
|
||
&conn,
|
||
"usage_daily_rollups",
|
||
"input_token_semantics"
|
||
)?);
|
||
let log_default: i64 = conn.query_row(
|
||
"SELECT dflt_value = '0' FROM pragma_table_info('proxy_request_logs')
|
||
WHERE name = 'input_token_semantics'",
|
||
[],
|
||
|row| row.get(0),
|
||
)?;
|
||
assert_eq!(log_default, 1);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
Database::create_tables_on_conn(&conn)?;
|
||
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
|
||
conn.execute(
|
||
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
|
||
[],
|
||
)?;
|
||
Database::set_user_version(&conn, 13)?;
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
let grok_rows: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
|
||
[],
|
||
|row| row.get(0),
|
||
)?;
|
||
assert_eq!(grok_rows, 1);
|
||
let codex_values: (i64, i64) = conn.query_row(
|
||
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)?;
|
||
assert_eq!(codex_values, (1, 9));
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn kimi_migration_preserves_existing_flags_and_defaults_to_disabled() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
conn.execute_batch("CREATE TABLE mcp_servers(id TEXT PRIMARY KEY, enabled_codex BOOLEAN); CREATE TABLE skills(id TEXT PRIMARY KEY, enabled_claude BOOLEAN); INSERT INTO mcp_servers VALUES ('m',1); INSERT INTO skills VALUES ('s',1);")?;
|
||
Database::set_user_version(&conn, 21)?;
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
for (table, flag) in [
|
||
("mcp_servers", "enabled_codex"),
|
||
("skills", "enabled_claude"),
|
||
] {
|
||
let flags = conn.query_row(
|
||
&format!("SELECT {flag}, enabled_kimi FROM {table}"),
|
||
[],
|
||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||
)?;
|
||
assert_eq!(flags, (1, 0));
|
||
}
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn kimi_clean_database_persists_mcp_and_skill_flags_independently() -> Result<(), AppError> {
|
||
let db = Database::memory()?;
|
||
let server = serde_json::from_value(serde_json::json!({"id":"m", "name":"m", "server":{"command":"echo"}, "apps":{"kimi":true,"codex":true}})).unwrap();
|
||
db.save_mcp_server(&server)?;
|
||
db.update_mcp_server_app_enabled("m", &crate::app_config::AppType::Kimi, false)?;
|
||
let saved = db.get_all_mcp_servers()?;
|
||
assert!(!saved["m"].apps.kimi);
|
||
assert!(saved["m"].apps.codex);
|
||
let skill = serde_json::from_value(serde_json::json!({"id":"s", "name":"s", "description":"", "directory":"s", "apps":{"kimi":true,"claude":true}, "installedAt":0})).unwrap();
|
||
db.save_skill(&skill)?;
|
||
let saved = db.get_installed_skill("s")?.unwrap();
|
||
assert!(saved.apps.kimi && saved.apps.claude);
|
||
assert!(db.get_all_installed_skills()?["s"].apps.kimi);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn agy_migration_preserves_existing_flags_and_defaults_to_disabled() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
conn.execute_batch("CREATE TABLE mcp_servers(id TEXT PRIMARY KEY, enabled_gemini BOOLEAN); CREATE TABLE skills(id TEXT PRIMARY KEY, enabled_codex BOOLEAN); INSERT INTO mcp_servers VALUES ('m',1); INSERT INTO skills VALUES ('s',1);")?;
|
||
Database::set_user_version(&conn, 20)?;
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
for (table, flag) in [
|
||
("mcp_servers", "enabled_gemini"),
|
||
("skills", "enabled_codex"),
|
||
] {
|
||
let row = conn.query_row(
|
||
&format!("SELECT {flag}, enabled_agy FROM {table}"),
|
||
[],
|
||
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
|
||
)?;
|
||
assert_eq!(row, (1, 0));
|
||
}
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn agy_clean_database_persists_independent_mcp_and_skill_flags() -> Result<(), AppError> {
|
||
let db = Database::memory()?;
|
||
let mut server: crate::app_config::McpServer = serde_json::from_value(serde_json::json!({"id":"m", "name":"m", "server":{"command":"echo"}, "apps":{"gemini":true,"agy":true}})).unwrap();
|
||
db.save_mcp_server(&server)?;
|
||
server.apps.agy = false;
|
||
db.update_mcp_server_app_enabled("m", &crate::app_config::AppType::Agy, false)?;
|
||
assert_eq!(db.get_all_mcp_servers()?["m"].apps, server.apps);
|
||
let skill: crate::app_config::InstalledSkill = serde_json::from_value(serde_json::json!({"id":"s", "name":"s", "directory":"s", "installedAt":0, "apps":{"agy":true,"gemini":true}})).unwrap();
|
||
db.save_skill(&skill)?;
|
||
assert!(db.get_installed_skill("s")?.unwrap().apps.agy);
|
||
let mut apps = skill.apps;
|
||
apps.agy = false;
|
||
db.update_skill_apps("s", &apps)?;
|
||
let read = db.get_all_installed_skills()?;
|
||
assert!(!read["s"].apps.agy);
|
||
assert!(read["s"].apps.gemini);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_v19_to_v20_adds_pi_mcp_flag_and_keeps_existing_flags() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
conn.execute_batch(
|
||
"CREATE TABLE mcp_servers (
|
||
id TEXT PRIMARY KEY,
|
||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||
enabled_mcode BOOLEAN NOT NULL DEFAULT 0
|
||
);
|
||
INSERT INTO mcp_servers (id, enabled_codex, enabled_mcode) VALUES ('mcp-1', 1, 1);",
|
||
)?;
|
||
Database::set_user_version(&conn, 19)?;
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
let values: (i64, i64, i64) = conn.query_row(
|
||
"SELECT enabled_codex, enabled_mcode, enabled_pi FROM mcp_servers WHERE id = 'mcp-1'",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||
)?;
|
||
assert_eq!(values, (1, 1, 0));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
conn.execute_batch(
|
||
"CREATE TABLE mcp_servers (
|
||
id TEXT PRIMARY KEY,
|
||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE skills (
|
||
id TEXT PRIMARY KEY,
|
||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||
);",
|
||
)?;
|
||
conn.execute(
|
||
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
|
||
[],
|
||
)?;
|
||
conn.execute(
|
||
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
|
||
[],
|
||
)?;
|
||
Database::set_user_version(&conn, 14)?;
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
assert!(Database::has_column(
|
||
&conn,
|
||
"mcp_servers",
|
||
"enabled_grokbuild"
|
||
)?);
|
||
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
|
||
let mcp_values: (i64, i64) = conn.query_row(
|
||
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)?;
|
||
let skill_values: (i64, i64) = conn.query_row(
|
||
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)?;
|
||
assert_eq!(mcp_values, (1, 0));
|
||
assert_eq!(skill_values, (1, 0));
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_v15_to_v16_resets_only_codex_session_usage() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
Database::create_tables_on_conn(&conn)?;
|
||
conn.execute_batch(
|
||
"INSERT INTO proxy_request_logs (
|
||
request_id, provider_id, app_type, model, input_tokens,
|
||
output_tokens, cache_read_tokens, latency_ms, status_code,
|
||
created_at, data_source
|
||
) VALUES
|
||
('codex-row', '_codex_session', 'codex', 'gpt', 1, 1, 0, 0, 200, 1, 'codex_session'),
|
||
('gemini-row', '_gemini_session', 'gemini', 'gemini', 1, 1, 0, 0, 200, 1, 'gemini_session');
|
||
INSERT INTO usage_daily_rollups (date, app_type, provider_id, model)
|
||
VALUES
|
||
('2026-07-10', 'codex', '_codex_session', 'gpt'),
|
||
('2026-07-10', 'gemini', '_gemini_session', 'gemini');
|
||
INSERT INTO session_log_sync
|
||
(file_path, last_modified, last_line_offset, last_synced_at)
|
||
VALUES
|
||
('/old/sessions/rollout-old-00000000-0000-4000-8000-000000000001.jsonl', 1, 1, 1),
|
||
('/gemini/tmp/session-123.json', 1, 1, 1);",
|
||
)?;
|
||
Database::set_user_version(&conn, 15)?;
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
assert!(Database::table_exists(&conn, "session_usage_dedup")?);
|
||
let counts: (i64, i64, i64, i64) = conn.query_row(
|
||
"SELECT
|
||
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
|
||
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'gemini_session'),
|
||
(SELECT COUNT(*) FROM usage_daily_rollups WHERE provider_id = '_codex_session'),
|
||
(SELECT COUNT(*) FROM session_log_sync)",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||
)?;
|
||
assert_eq!(counts, (0, 1, 0, 1));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_v16_to_v17_creates_session_usage_dedup_ledger() -> Result<(), AppError> {
|
||
let conn = Connection::open_in_memory()?;
|
||
Database::set_user_version(&conn, 16)?;
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||
assert!(Database::table_exists(&conn, "session_usage_dedup")?);
|
||
conn.execute(
|
||
"INSERT INTO session_usage_dedup
|
||
(data_source, request_id, semantic_id, has_entry_id)
|
||
VALUES ('pi_session', 'request', 'semantic', 1)",
|
||
[],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_v17_to_v18_adds_byte_cursor_to_existing_sync_table() -> Result<(), AppError> {
|
||
// 真实升级路径:v17 库带旧 DDL 的 session_log_sync(无字节游标列,
|
||
// 字节游标曾短暂搭 v17 车、已执行过 v17 的开发库正是这个形状)
|
||
// 与存量游标行,迁移后列补上、存量行保持 NULL(首轮按行号转换)
|
||
let conn = Connection::open_in_memory()?;
|
||
conn.execute_batch(
|
||
"CREATE TABLE session_log_sync (
|
||
file_path TEXT PRIMARY KEY,
|
||
last_modified INTEGER NOT NULL,
|
||
last_line_offset INTEGER NOT NULL DEFAULT 0,
|
||
last_synced_at INTEGER NOT NULL
|
||
);
|
||
INSERT INTO session_log_sync VALUES ('/tmp/a.jsonl', 5, 3, 1);",
|
||
)?;
|
||
Database::set_user_version(&conn, 17)?;
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
assert!(Database::has_column(
|
||
&conn,
|
||
"session_log_sync",
|
||
"last_byte_offset"
|
||
)?);
|
||
assert!(Database::has_column(
|
||
&conn,
|
||
"session_log_sync",
|
||
"last_tail_fingerprint"
|
||
)?);
|
||
let (byte_offset, fingerprint): (Option<i64>, Option<i64>) = conn.query_row(
|
||
"SELECT last_byte_offset, last_tail_fingerprint
|
||
FROM session_log_sync WHERE file_path = '/tmp/a.jsonl'",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)?;
|
||
assert_eq!(byte_offset, None, "存量行的字节游标必须为 NULL");
|
||
assert_eq!(fingerprint, None, "存量行的尾部指纹必须为 NULL");
|
||
Ok(())
|
||
}
|
||
}
|