//! Schema 定义和迁移 //! //! 负责数据库表结构的创建和版本迁移。 use super::{lock_conn, Database, SCHEMA_VERSION}; use crate::error::AppError; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; use tempfile::NamedTempFile; /// A disk-backed database whose schema was created from this binary's current /// schema code. Its fields are intentionally private: untrusted connections /// cannot be wrapped or converted into a publishable stage. pub(super) struct CanonicalStage { connection: Connection, _file: NamedTempFile, } impl CanonicalStage { pub(super) fn connection(&self) -> &Connection { &self.connection } pub(super) fn connection_mut(&mut self) -> &mut Connection { &mut self.connection } } /// Selects the trusted local-upgrade path or the construction-only N/N-1 /// restore path. /// /// # Historical migration classification (blind-review authority) /// /// LocalUpgrade retains the complete v1→v17 in-place chain below. The untrusted /// SQL/binary entries are narrower: `UntrustedScratch` rejects v1..v15 before /// this dispatcher, so only v16→v17 is reachable in `UntrustedRestore`. /// /// A supported untrusted database must exactly match the v16 or v17 /// `MigrationSourceSpec`: table set, column set, and every populated SQLite /// storage class. No current-schema table is created during recognition. The /// v16→v17 step is checked mechanically by /// `validate_migration_mapping_completeness`, and its complete v17 target spec /// is revalidated before a separately constructed canonical stage is populated. /// /// Rows v1→v2 through v15→v16 document and protect LocalUpgrade behavior and /// preserve design groundwork for a future “historical backup import” project; /// they are not accepted untrusted restore paths. /// /// “Total shape map” means all source values are preserved under that declared /// mapping and all added values are version-owned structural sentinels. /// “Typed transform” means rows are decoded, value-domain checked, and mapped /// deterministically; malformed or ambiguous input returns structured /// `InvalidInput`. Local repair behavior is never reachable from /// `UntrustedRestore`. /// /// | Step | Class | Transform and preservation basis | /// | --- | --- | --- | /// | v1→v2 | Typed transform + total shape map | The singleton proxy and circuit rows require `id=1`; every field fans out unchanged to the three app rows owned by v2, six ownership settings remain byte-preserved while also producing explicit app flags, and prefixed skill keys map to typed `(directory, app_type)` rows. Missing/malformed rows, unsupported keys, booleans, or numeric domains abort. All settings and identity columns survive; new timestamps use a fixed structural sentinel and v2-owned empty tables are created without canonical seed data. | /// | v2→v3 | Typed transform | Every skill row decodes as `(directory, app_type, installed, installed_at)` and deterministically produces id/name/directory/app enablement; empty directories, unsupported apps, invalid booleans, or collisions abort. No row is deferred to a filesystem rescan. | /// | v3→v4 | Total shape map | All source columns are identity-mapped; OpenCode flags are version-owned false sentinels. | /// | v4→v5 | Total shape map | All source columns are identity-mapped; request-model and proxy pricing columns use declared version sentinels. | /// | v5→v6 | Total shape map + typed validation | Provider meta bytes are preserved; malformed provider JSON is rejected at canonical hydration instead of normalized. The new rollup table starts empty. | /// | v6→v7 | Total shape map | All source columns are identity-mapped; skill hash/timestamp columns use declared version sentinels. | /// | v7→v8 | Total shape map | Pricing rows remain byte-for-byte source-owned; log `data_source` and the device cursor table are structural additions. No pricing correction runs. | /// | v8→v9 | Total shape map | Every source value is identity-mapped; untrusted model pricing is never reseeded. | /// | v9→v10 | Total shape map | All source columns are identity-mapped; Hermes flags are version-owned false sentinels. | /// | v10→v11 | Typed transform | Every rollup source column is copied through an explicit projection; newly introduced request/pricing dimensions receive the version-defined unknown sentinel. Decode, insert, or collision failure aborts. | /// | v11→v12 | Total shape map | All source values are identity-mapped and the new profiles table starts empty. | /// | v12→v13 | Total shape map | All source values are identity-mapped; input-token semantics columns receive the declared unknown sentinel. | /// | v13→v14 | Typed transform | The three supported proxy app rows are projected column-for-column, `live_takeover_active` receives the version-owned false sentinel, and the version-owned Grok row is inserted with fixed structural defaults. Missing columns, storage mismatches, unsupported app identities, invalid values, or collisions abort. | /// | v14→v15 | Total shape map | All source values are identity-mapped; Grok Build enablement flags are version-owned false sentinels. | /// | v15→v16 | Total shape map | Logs, rollups, and cursor rows remain unchanged. The historical filesystem-dependent Codex reset is restricted to `LocalUpgrade`; final restore policy separately excludes device-local cursors. | /// | v16→v17 | Typed transform + total shape map | Endpoint rows preserve ids and timestamps while the new `last_used` field is null; duplicate logical endpoints abort instead of merging. Skills gain a false Pi bit, and device-local ownership ledgers start empty. | #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MigrationRunContext { LocalUpgrade, UntrustedRestore, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum CanonicalRestoreClass { MigrateAndValidate, RebuildAndPreserveLocal, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CanonicalColumnSpec { pub name: &'static str, pub data_type: &'static str, pub not_null: bool, pub default: Option<&'static str>, pub pk_position: i64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CanonicalIndexedColumnSpec { pub name: &'static str, pub collation: &'static str, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CanonicalUniqueSpec { pub columns: &'static [CanonicalIndexedColumnSpec], } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CanonicalForeignKeySpec { pub from: &'static [&'static str], pub table: &'static str, pub to: &'static [&'static str], pub on_update: &'static str, pub on_delete: &'static str, pub match_type: &'static str, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum SchemaInvariantKind { EndpointIdentityUnique, EndpointParentForeignKey, EndpointDeleteCascade, ProjectionProviderKeyUnique, SkillAppTypePiOnly, SkillMethodAllowed, SkillDestinationOwnedOnce, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SchemaInvariantSpec { pub name: &'static str, pub kind: SchemaInvariantKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct CanonicalTableSpec { pub name: &'static str, pub definition: &'static str, pub restore_class: CanonicalRestoreClass, pub columns: &'static [CanonicalColumnSpec], pub unique_tuples: &'static [CanonicalUniqueSpec], pub foreign_keys: &'static [CanonicalForeignKeySpec], pub invariants: &'static [SchemaInvariantSpec], } const PROVIDERS_COLUMNS: &[CanonicalColumnSpec] = &[ CanonicalColumnSpec { name: "id", data_type: "TEXT", not_null: true, default: None, pk_position: 1, }, CanonicalColumnSpec { name: "app_type", data_type: "TEXT", not_null: true, default: None, pk_position: 2, }, CanonicalColumnSpec { name: "name", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "settings_config", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "website_url", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "category", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "created_at", data_type: "INTEGER", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "sort_index", data_type: "INTEGER", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "notes", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "icon", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "icon_color", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "meta", data_type: "TEXT", not_null: true, default: Some("'{}'"), pk_position: 0, }, CanonicalColumnSpec { name: "is_current", data_type: "BOOLEAN", not_null: true, default: Some("0"), pk_position: 0, }, CanonicalColumnSpec { name: "in_failover_queue", data_type: "BOOLEAN", not_null: true, default: Some("0"), pk_position: 0, }, CanonicalColumnSpec { name: "cost_multiplier", data_type: "TEXT", not_null: true, default: Some("'1.0'"), pk_position: 0, }, CanonicalColumnSpec { name: "limit_daily_usd", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "limit_monthly_usd", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "provider_type", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, ]; const PROVIDER_ENDPOINT_COLUMNS: &[CanonicalColumnSpec] = &[ CanonicalColumnSpec { name: "id", data_type: "INTEGER", not_null: false, default: None, pk_position: 1, }, CanonicalColumnSpec { name: "provider_id", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "app_type", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "url", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "added_at", data_type: "INTEGER", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "last_used", data_type: "INTEGER", not_null: false, default: None, pk_position: 0, }, ]; const PI_PROJECTION_COLUMNS: &[CanonicalColumnSpec] = &[ CanonicalColumnSpec { name: "provider_id", data_type: "TEXT", not_null: false, default: None, pk_position: 1, }, CanonicalColumnSpec { name: "provider_key", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "created_at", data_type: "INTEGER", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "updated_at", data_type: "INTEGER", not_null: true, default: None, pk_position: 0, }, ]; const SKILL_DEPLOYMENT_COLUMNS: &[CanonicalColumnSpec] = &[ CanonicalColumnSpec { name: "app_type", data_type: "TEXT", not_null: true, default: None, pk_position: 1, }, CanonicalColumnSpec { name: "skill_id", data_type: "TEXT", not_null: true, default: None, pk_position: 2, }, CanonicalColumnSpec { name: "destination", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "destination_key", data_type: "TEXT", not_null: true, default: None, pk_position: 3, }, CanonicalColumnSpec { name: "method", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "source_identity", data_type: "TEXT", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "deployed_digest", data_type: "TEXT", not_null: false, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "created_at", data_type: "INTEGER", not_null: true, default: None, pk_position: 0, }, CanonicalColumnSpec { name: "updated_at", data_type: "INTEGER", not_null: true, default: None, pk_position: 0, }, ]; const ENDPOINT_IDENTITY_COLUMNS: &[CanonicalIndexedColumnSpec] = &[ CanonicalIndexedColumnSpec { name: "provider_id", collation: "BINARY", }, CanonicalIndexedColumnSpec { name: "app_type", collation: "BINARY", }, CanonicalIndexedColumnSpec { name: "url", collation: "BINARY", }, ]; const PROJECTION_KEY_COLUMNS: &[CanonicalIndexedColumnSpec] = &[CanonicalIndexedColumnSpec { name: "provider_key", collation: "BINARY", }]; const SKILL_DESTINATION_COLUMNS: &[CanonicalIndexedColumnSpec] = &[ CanonicalIndexedColumnSpec { name: "app_type", collation: "BINARY", }, CanonicalIndexedColumnSpec { name: "destination_key", collation: "BINARY", }, ]; const ENDPOINT_FOREIGN_KEYS: &[CanonicalForeignKeySpec] = &[CanonicalForeignKeySpec { from: &["provider_id", "app_type"], table: "providers", to: &["id", "app_type"], on_update: "NO ACTION", on_delete: "CASCADE", match_type: "NONE", }]; const ENDPOINT_INVARIANTS: &[SchemaInvariantSpec] = &[ SchemaInvariantSpec { name: "endpoint_identity_unique", kind: SchemaInvariantKind::EndpointIdentityUnique, }, SchemaInvariantSpec { name: "endpoint_parent_fk", kind: SchemaInvariantKind::EndpointParentForeignKey, }, SchemaInvariantSpec { name: "endpoint_delete_cascade", kind: SchemaInvariantKind::EndpointDeleteCascade, }, ]; const PROJECTION_INVARIANTS: &[SchemaInvariantSpec] = &[SchemaInvariantSpec { name: "provider_key_unique", kind: SchemaInvariantKind::ProjectionProviderKeyUnique, }]; const SKILL_INVARIANTS: &[SchemaInvariantSpec] = &[ SchemaInvariantSpec { name: "app_type_pi_only", kind: SchemaInvariantKind::SkillAppTypePiOnly, }, SchemaInvariantSpec { name: "method_symlink_or_copy", kind: SchemaInvariantKind::SkillMethodAllowed, }, SchemaInvariantSpec { name: "destination_owned_once", kind: SchemaInvariantKind::SkillDestinationOwnedOnce, }, ]; pub(crate) const CANONICAL_TABLE_SPECS: &[CanonicalTableSpec] = &[ CanonicalTableSpec { name: "providers", definition: "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, cost_multiplier TEXT NOT NULL DEFAULT '1.0', limit_daily_usd TEXT, limit_monthly_usd TEXT, provider_type TEXT, PRIMARY KEY (id, app_type) )", restore_class: CanonicalRestoreClass::MigrateAndValidate, columns: PROVIDERS_COLUMNS, unique_tuples: &[], foreign_keys: &[], invariants: &[], }, CanonicalTableSpec { name: "provider_endpoints", definition: "provider_endpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, url TEXT NOT NULL, added_at INTEGER, last_used INTEGER, FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE, UNIQUE (provider_id, app_type, url) )", restore_class: CanonicalRestoreClass::MigrateAndValidate, columns: PROVIDER_ENDPOINT_COLUMNS, unique_tuples: &[CanonicalUniqueSpec { columns: ENDPOINT_IDENTITY_COLUMNS, }], foreign_keys: ENDPOINT_FOREIGN_KEYS, invariants: ENDPOINT_INVARIANTS, }, CanonicalTableSpec { name: "pi_provider_projections", definition: "pi_provider_projections ( provider_id TEXT PRIMARY KEY, provider_key TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )", restore_class: CanonicalRestoreClass::RebuildAndPreserveLocal, columns: PI_PROJECTION_COLUMNS, unique_tuples: &[CanonicalUniqueSpec { columns: PROJECTION_KEY_COLUMNS, }], foreign_keys: &[], invariants: PROJECTION_INVARIANTS, }, CanonicalTableSpec { name: "skill_deployments", definition: "skill_deployments ( app_type TEXT NOT NULL CHECK (app_type = 'pi'), skill_id TEXT NOT NULL, destination TEXT NOT NULL, destination_key TEXT NOT NULL, method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')), source_identity TEXT NOT NULL, deployed_digest TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (app_type, skill_id, destination_key), UNIQUE (app_type, destination_key) )", restore_class: CanonicalRestoreClass::RebuildAndPreserveLocal, columns: SKILL_DEPLOYMENT_COLUMNS, unique_tuples: &[CanonicalUniqueSpec { columns: SKILL_DESTINATION_COLUMNS, }], foreign_keys: &[], invariants: SKILL_INVARIANTS, }, ]; #[derive(Serialize)] struct LegacySkillMigrationRow { directory: String, app_type: String, } #[derive(Debug, Clone)] struct LegacyV1ProxyConfig { id: i64, proxy_enabled: i64, listen_address: String, listen_port: i64, enable_logging: i64, max_retries: i64, streaming_first_byte_timeout: i64, streaming_idle_timeout: i64, non_streaming_timeout: i64, } #[derive(Debug, Clone)] struct LegacyV1CircuitBreaker { id: i64, failure_threshold: i64, success_threshold: i64, timeout_seconds: i64, error_rate_threshold: f64, min_requests: i64, } pub(crate) const UNTRUSTED_MIGRATION_TIMESTAMP: &str = "1970-01-01 00:00:00"; impl Database { /// Construct a publish-capable stage directly from the current schema /// factory. Untrusted historical migration is a separate path and never /// supplies DDL to this factory. pub(super) fn current_canonical_stage() -> Result { let file = NamedTempFile::new().map_err(|error| AppError::IoContext { context: "create canonical restore stage".to_string(), source: error, })?; let connection = Connection::open(file.path()).map_err(|error| AppError::Database(error.to_string()))?; connection .execute_batch( "PRAGMA auto_vacuum = INCREMENTAL; PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF;", ) .map_err(|error| AppError::Database(error.to_string()))?; // The canonical factory is intentionally not implemented by replaying // untrusted migrations. Its only inputs are current, trusted DDL and // canonical seed data. Self::create_tables_on_conn(&connection, MigrationRunContext::LocalUpgrade)?; Self::ensure_model_pricing_seeded_on_conn(&connection)?; Self::set_user_version(&connection, SCHEMA_VERSION)?; if Self::get_user_version(&connection)? != SCHEMA_VERSION { return Err(AppError::Database( "canonical stage factory did not reach the current schema version".to_string(), )); } Ok(CanonicalStage { connection, _file: file, }) } pub(crate) fn canonical_table_spec( name: &str, ) -> Result<&'static CanonicalTableSpec, AppError> { CANONICAL_TABLE_SPECS .iter() .find(|spec| spec.name == name) .ok_or_else(|| AppError::Config(format!("unknown canonical table '{name}'"))) } fn create_canonical_table_on_conn( conn: &Connection, name: &str, if_not_exists: bool, ) -> Result<(), AppError> { let spec = Self::canonical_table_spec(name)?; Self::create_canonical_table_as_on_conn(conn, spec, name, if_not_exists) } fn create_canonical_table_as_on_conn( conn: &Connection, spec: &CanonicalTableSpec, target_name: &str, if_not_exists: bool, ) -> Result<(), AppError> { let qualifier = if if_not_exists { " IF NOT EXISTS" } else { "" }; let definition = if target_name == spec.name { spec.definition.to_string() } else { spec.definition.replacen(spec.name, target_name, 1) }; conn.execute(&format!("CREATE TABLE{qualifier} {definition}"), []) .map_err(|error| { AppError::Database(format!( "failed to create canonical table '{target_name}': {error}" )) })?; Ok(()) } /// 创建所有数据库表 pub(crate) fn create_tables(&self) -> Result<(), AppError> { let conn = lock_conn!(self.conn); Self::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade) } /// 在指定连接上创建表(供迁移和测试使用) pub(crate) fn create_tables_on_conn( conn: &Connection, context: MigrationRunContext, ) -> Result<(), AppError> { if context != MigrationRunContext::LocalUpgrade { return Err(AppError::Config( "current-schema factory is unavailable to untrusted migration".to_string(), )); } // 1. Providers 表 Self::create_canonical_table_on_conn(conn, "providers", true)?; // 2. Provider Endpoints 表 Self::create_canonical_table_on_conn(conn, "provider_endpoints", true)?; // 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_hermes 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_hermes BOOLEAN NOT NULL DEFAULT 0, enabled_pi 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()))?; // Exact models.json ownership is device-local and must never be // inferred from provider names, content, or prefixes. Self::create_canonical_table_on_conn(conn, "pi_provider_projections", true)?; // Pi Skill deployment ownership is independent from desired enablement // in `skills.enabled_pi` and from live discovery. Self::create_canonical_table_on_conn(conn, "skill_deployments", true)?; // 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 context == MigrationRunContext::LocalUpgrade && 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 表 (会话日志同步状态) conn.execute( "CREATE TABLE IF NOT EXISTS session_log_sync ( file_path TEXT PRIMARY KEY, last_modified INTEGER NOT NULL, last_line_offset INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; // 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()))?; if context == MigrationRunContext::LocalUpgrade { // These compatibility repairs are intentionally local-only. An // untrusted restore must be accepted by its declared migration // version and canonical validation, never silently normalized by // startup repair code. 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'", []); } let _ = conn.execute( "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0", [], ); 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", [], ); 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", [], ); if Self::table_exists(conn, "proxy_config")? && !Self::has_column(conn, "proxy_config", "app_type")? { Self::migrate_proxy_config_to_per_app(conn, MigrationRunContext::LocalUpgrade)?; } Self::add_column_if_missing( conn, "providers", "in_failover_queue", "BOOLEAN NOT NULL DEFAULT 0", )?; let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []); let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []); 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, MigrationRunContext::LocalUpgrade) } /// 在指定连接上应用 Schema 迁移 pub(crate) fn apply_schema_migrations_on_conn( conn: &Connection, context: MigrationRunContext, ) -> 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 { Self::rollback_schema_migration_savepoint(conn); 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, context)?; Self::set_user_version(conn, 1)?; } 1 => { log::info!( "迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)" ); Self::migrate_v1_to_v2(conn, context)?; Self::set_user_version(conn, 2)?; } 2 => { log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)"); Self::migrate_v2_to_v3(conn, context)?; Self::set_user_version(conn, 3)?; } 3 => { log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)"); Self::migrate_v3_to_v4(conn, context)?; Self::set_user_version(conn, 4)?; } 4 => { log::info!("迁移数据库从 v4 到 v5(计费模式支持)"); Self::migrate_v4_to_v5(conn, context)?; Self::set_user_version(conn, 5)?; } 5 => { log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一)"); Self::migrate_v5_to_v6(conn, context)?; Self::set_user_version(conn, 6)?; } 6 => { log::info!("迁移数据库从 v6 到 v7(Skills 更新检测支持)"); Self::migrate_v6_to_v7(conn, context)?; Self::set_user_version(conn, 7)?; } 7 => { log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)"); Self::migrate_v7_to_v8(conn, context)?; Self::set_user_version(conn, 8)?; } 8 => { log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)"); Self::migrate_v8_to_v9(conn, context)?; Self::set_user_version(conn, 9)?; } 9 => { log::info!("迁移数据库从 v9 到 v10(添加 Hermes Agent 支持)"); Self::migrate_v9_to_v10(conn, context)?; Self::set_user_version(conn, 10)?; } 10 => { log::info!("迁移数据库从 v10 到 v11(usage_daily_rollups 保留 request_model 维度)"); Self::migrate_v10_to_v11(conn, context)?; Self::set_user_version(conn, 11)?; } 11 => { log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)"); Self::migrate_v11_to_v12(conn, context)?; Self::set_user_version(conn, 12)?; } 12 => { log::info!("迁移数据库从 v12 到 v13(记录输入 token 缓存语义)"); Self::migrate_v12_to_v13(conn, context)?; Self::set_user_version(conn, 13)?; } 13 => { log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)"); Self::migrate_v13_to_v14(conn, context)?; Self::set_user_version(conn, 14)?; } 14 => { log::info!("迁移数据库从 v14 到 v15(Skills/MCP 添加 Grok Build 支持)"); Self::migrate_v14_to_v15(conn, context)?; Self::set_user_version(conn, 15)?; } 15 => { log::info!("迁移数据库从 v15 到 v16(重建 Codex 会话用量)"); Self::migrate_v15_to_v16(conn, context)?; Self::set_user_version(conn, 16)?; } 16 => { log::info!( "迁移数据库从 v16 到 v17(添加 Pi aggregate 与设备本地 ledger)" ); Self::migrate_v16_to_v17(conn, context)?; Self::set_user_version(conn, 17)?; } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" ))); } } let migrated_version = Self::get_user_version(conn)?; if context == MigrationRunContext::UntrustedRestore { Self::validate_migration_source_version(conn, migrated_version)?; } version = migrated_version; } Ok(()) })(); match result { Ok(_) => { conn.execute("RELEASE schema_migration;", []) .map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?; Ok(()) } Err(e) => { Self::rollback_schema_migration_savepoint(conn); if context == MigrationRunContext::UntrustedRestore { match e { AppError::InvalidInput(_) => Err(e), other => Err(AppError::InvalidInput(format!( "untrusted schema migration v{version}->v{} failed: {other}", version + 1 ))), } } else { Err(e) } } } } fn rollback_schema_migration_savepoint(conn: &Connection) { if let Err(error) = conn.execute("ROLLBACK TO schema_migration;", []) { log::error!("failed to roll back schema migration savepoint: {error}"); } if let Err(error) = conn.execute("RELEASE schema_migration;", []) { log::error!("failed to release failed schema migration savepoint: {error}"); } } fn untrusted_repair_error(step: &str, detail: impl std::fmt::Display) -> AppError { AppError::InvalidInput(format!( "untrusted migration {step} requires forbidden repair or synthesis: {detail}" )) } fn require_untrusted_integer_range( context: MigrationRunContext, step: &str, field: &str, value: i64, range: std::ops::RangeInclusive, ) -> Result<(), AppError> { if context == MigrationRunContext::UntrustedRestore && !range.contains(&value) { return Err(AppError::InvalidInput(format!( "untrusted migration {step} field {field}={value} is outside {}..={}", range.start(), range.end() ))); } Ok(()) } fn require_untrusted_finite_unit_interval( context: MigrationRunContext, step: &str, field: &str, value: f64, ) -> Result<(), AppError> { if context == MigrationRunContext::UntrustedRestore && (!value.is_finite() || !(0.0..=1.0).contains(&value)) { return Err(AppError::InvalidInput(format!( "untrusted migration {step} field {field}={value} is not finite within 0..=1" ))); } Ok(()) } fn legacy_query_or_local_default( context: MigrationRunContext, step: &str, result: rusqlite::Result, local_default: T, ) -> Result { match result { Ok(value) => Ok(value), Err(error) if context == MigrationRunContext::UntrustedRestore => { Err(Self::untrusted_repair_error( step, format!("authoritative row is missing or undecodable: {error}"), )) } Err(_) => Ok(local_default), } } fn require_untrusted_singleton_table( conn: &Connection, context: MigrationRunContext, step: &str, table: &str, ) -> Result<(), AppError> { if context != MigrationRunContext::UntrustedRestore { return Ok(()); } Self::validate_identifier(table, "legacy singleton table")?; let count: i64 = conn .query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |row| { row.get(0) }) .map_err(|error| { Self::untrusted_repair_error( step, format!("failed to count authoritative rows in {table}: {error}"), ) })?; if count != 1 { return Err(Self::untrusted_repair_error( step, format!("{table} must contain exactly one authoritative row, found {count}"), )); } Ok(()) } fn legacy_bool_or_local_false( conn: &Connection, context: MigrationRunContext, step: &str, key: &str, ) -> Result { if context == MigrationRunContext::UntrustedRestore { let count: i64 = conn .query_row( "SELECT COUNT(*) FROM settings WHERE key = ?1", [key], |row| row.get(0), ) .map_err(|error| { Self::untrusted_repair_error( step, format!("failed to count required key {key:?}: {error}"), ) })?; if count != 1 { return Err(Self::untrusted_repair_error( step, format!("required key {key:?} must occur exactly once, found {count}"), )); } } let value = conn .query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { row.get::<_, String>(0) }) .optional(); match value { Ok(Some(value)) => match value.as_str() { "true" | "1" => Ok(true), "false" | "0" => Ok(false), _ if context == MigrationRunContext::UntrustedRestore => { Err(Self::untrusted_repair_error( step, format!("{key} has unsupported boolean value {value:?}"), )) } _ => Ok(false), }, Ok(None) if context == MigrationRunContext::LocalUpgrade => Ok(false), Ok(None) => Err(Self::untrusted_repair_error( step, format!("required key {key:?} is missing"), )), Err(error) if context == MigrationRunContext::UntrustedRestore => Err( Self::untrusted_repair_error(step, format!("failed to decode {key:?}: {error}")), ), Err(_) => Ok(false), } } /// v0 -> v1 迁移:补齐所有缺失列 fn migrate_v0_to_v1(conn: &Connection, _context: MigrationRunContext) -> 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, context: MigrationRunContext) -> 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}")))?; // v2-owned tables are created by this migration, never by the current // canonical factory. Their columns are the declared v2 source shape. 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(|error| AppError::Database(format!("create v2 provider_health: {error}")))?; 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, 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_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 )", [], ) .map_err(|error| AppError::Database(format!("create v2 proxy_request_logs: {error}")))?; // Validate/convert the two legacy structures before any data seed // replacement. This makes a malformed legacy row the reported cause // instead of masking it behind a later repair decision. Self::migrate_skills_table(conn, context)?; Self::migrate_proxy_config_to_per_app(conn, context)?; // 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( "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(|error| AppError::Database(format!("create v2 stream_check_logs: {error}")))?; 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(|error| AppError::Database(format!("create v2 proxy_live_backup: {error}")))?; if context == MigrationRunContext::LocalUpgrade { Self::seed_model_pricing(conn)?; } Ok(()) } /// 将 proxy_config 迁移为三行结构(每应用独立配置) fn migrate_proxy_config_to_per_app( conn: &Connection, context: MigrationRunContext, ) -> Result<(), AppError> { if !Self::table_exists(conn, "proxy_config")? { if context == MigrationRunContext::UntrustedRestore { return Err(AppError::InvalidInput( "untrusted v1 source is missing proxy_config".to_string(), )); } return Ok(()); } if Self::has_column(conn, "proxy_config", "app_type")? { if context == MigrationRunContext::UntrustedRestore { return Err(AppError::InvalidInput( "untrusted v1 proxy_config already has the v2 app_type shape".to_string(), )); } log::info!("proxy_config 已经是三行结构,跳过迁移"); return Ok(()); } Self::require_untrusted_singleton_table( conn, context, "v1->v2 proxy_config id=1", "proxy_config", )?; let old_config_result = conn.query_row( "SELECT id, proxy_enabled, listen_address, listen_port, enable_logging, max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout FROM proxy_config WHERE id = 1", [], |row| { Ok(LegacyV1ProxyConfig { id: row.get(0)?, proxy_enabled: row.get(1)?, listen_address: row.get(2)?, listen_port: row.get(3)?, enable_logging: row.get(4)?, max_retries: row.get(5)?, streaming_first_byte_timeout: row.get(6)?, streaming_idle_timeout: row.get(7)?, non_streaming_timeout: row.get(8)?, }) }, ); let old_config = Self::legacy_query_or_local_default( context, "v1->v2 proxy_config id=1", old_config_result, LegacyV1ProxyConfig { id: 1, proxy_enabled: 0, listen_address: "127.0.0.1".to_string(), listen_port: 5000, enable_logging: 1, max_retries: 3, streaming_first_byte_timeout: 30, streaming_idle_timeout: 60, non_streaming_timeout: 300, }, )?; Self::require_untrusted_singleton_table( conn, context, "v1->v2 circuit_breaker_config id=1", "circuit_breaker_config", )?; let old_cb_result = if Self::table_exists(conn, "circuit_breaker_config")? { conn.query_row( "SELECT id, failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests FROM circuit_breaker_config WHERE id = 1", [], |row| { Ok(LegacyV1CircuitBreaker { id: row.get(0)?, failure_threshold: row.get(1)?, success_threshold: row.get(2)?, timeout_seconds: row.get(3)?, error_rate_threshold: row.get(4)?, min_requests: row.get(5)?, }) }, ) } else { Err(rusqlite::Error::QueryReturnedNoRows) }; let old_cb = Self::legacy_query_or_local_default( context, "v1->v2 circuit_breaker_config id=1", old_cb_result, LegacyV1CircuitBreaker { id: 1, failure_threshold: 5, success_threshold: 2, timeout_seconds: 60, error_rate_threshold: 0.5, min_requests: 10, }, )?; Self::require_untrusted_integer_range( context, "v1->v2 proxy fan-out", "proxy_config.id", old_config.id, 1..=1, )?; Self::require_untrusted_integer_range( context, "v1->v2 proxy fan-out", "proxy_enabled", old_config.proxy_enabled, 0..=1, )?; Self::require_untrusted_integer_range( context, "v1->v2 proxy fan-out", "listen_port", old_config.listen_port, 0..=u16::MAX.into(), )?; Self::require_untrusted_integer_range( context, "v1->v2 proxy fan-out", "enable_logging", old_config.enable_logging, 0..=1, )?; Self::require_untrusted_integer_range( context, "v1->v2 proxy fan-out", "max_retries", old_config.max_retries, 0..=u8::MAX.into(), )?; for (field, value) in [ ( "streaming_first_byte_timeout", old_config.streaming_first_byte_timeout, ), ("streaming_idle_timeout", old_config.streaming_idle_timeout), ("non_streaming_timeout", old_config.non_streaming_timeout), ] { Self::require_untrusted_integer_range( context, "v1->v2 proxy fan-out", field, value, 0..=i32::MAX.into(), )?; } Self::require_untrusted_integer_range( context, "v1->v2 circuit fan-out", "circuit_breaker_config.id", old_cb.id, 1..=1, )?; for (field, value) in [ ("failure_threshold", old_cb.failure_threshold), ("success_threshold", old_cb.success_threshold), ("timeout_seconds", old_cb.timeout_seconds), ("min_requests", old_cb.min_requests), ] { Self::require_untrusted_integer_range( context, "v1->v2 circuit fan-out", field, value, 0..=i32::MAX.into(), )?; } Self::require_untrusted_finite_unit_interval( context, "v1->v2 circuit fan-out", "error_rate_threshold", old_cb.error_rate_threshold, )?; let get_bool = |key: &str| { Self::legacy_bool_or_local_false(conn, context, "v1->v2 proxy ownership settings", key) }; let mut apps = vec![ ( "claude", get_bool("proxy_takeover_claude")?, get_bool("auto_failover_enabled_claude")?, ), ( "codex", get_bool("proxy_takeover_codex")?, get_bool("auto_failover_enabled_codex")?, ), ( "gemini", get_bool("proxy_takeover_gemini")?, get_bool("auto_failover_enabled_gemini")?, ), ]; if context == MigrationRunContext::LocalUpgrade { apps.push(("grokbuild", false, false)); } 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, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) )", [])?; let structural_timestamp = if context == MigrationRunContext::UntrustedRestore { UNTRUSTED_MIGRATION_TIMESTAMP.to_string() } else { conn.query_row("SELECT datetime('now')", [], |row| row.get::<_, String>(0)) .map_err(|error| { AppError::Database(format!( "read v1->v2 structural migration timestamp: {error}" )) })? }; for (app, takeover, failover) 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, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", rusqlite::params![ app, old_config.proxy_enabled, old_config.listen_address, old_config.listen_port, old_config.enable_logging, if takeover { 1 } else { 0 }, if failover { 1 } else { 0 }, old_config.max_retries, old_config.streaming_first_byte_timeout, old_config.streaming_idle_timeout, old_config.non_streaming_timeout, old_cb.failure_threshold, old_cb.success_threshold, old_cb.timeout_seconds, old_cb.error_rate_threshold, old_cb.min_requests, structural_timestamp, structural_timestamp, ] ).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", [])?; if context == MigrationRunContext::LocalUpgrade { conn.execute( "DELETE FROM settings WHERE key IN ( 'proxy_takeover_claude', 'auto_failover_enabled_claude', 'proxy_takeover_codex', 'auto_failover_enabled_codex', 'proxy_takeover_gemini', 'auto_failover_enabled_gemini' )", [], )?; } log::info!("proxy_config 已迁移为三行结构"); Ok(()) } /// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键 fn migrate_skills_table( conn: &Connection, context: MigrationRunContext, ) -> 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, i64, i64)> = stmt .query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, )) }) .map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))? .collect::, _>>() .map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?; let count = old_skills.len(); for (key, installed, installed_at) in old_skills { Self::require_untrusted_integer_range( context, "v1->v2 skills", "installed", installed, 0..=1, )?; let installed = if context == MigrationRunContext::LocalUpgrade { i64::from(installed != 0) } else { installed }; // 解析 key: "app:directory" 或 "directory"(默认 claude) let (app_type, directory) = if let Some((app, directory)) = key.split_once(':') { if app.is_empty() || directory.is_empty() { return Err(Self::untrusted_repair_error( "v1->v2 skills", format!("legacy skill key {key:?} has an empty component"), )); } if context == MigrationRunContext::UntrustedRestore && !matches!(app, "claude" | "codex" | "gemini") { return Err(Self::untrusted_repair_error( "v1->v2 skills", format!("legacy skill key {key:?} has unsupported app {app:?}"), )); } (app.to_string(), directory.to_string()) } else if context == MigrationRunContext::UntrustedRestore { return Err(Self::untrusted_repair_error( "v1->v2 skills", format!("legacy skill key {key:?} requires a synthesized claude prefix"), )); } 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, context: MigrationRunContext) -> Result<(), AppError> { // 检查是否已经是新结构(通过检查是否有 enabled_claude 列) if Self::has_column(conn, "skills", "enabled_claude")? { log::info!("skills 表已经是 v3 结构,跳过迁移"); return Ok(()); } log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)..."); // Capture typed rows before the shape changes. Local upgrades keep the // historical filesystem-reconciliation marker; untrusted restores use // a total, deterministic row mapping and never discard these rows. let old_count: i64 = conn .query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0)) .map_err(|error| AppError::Database(format!("统计旧 skills 数据失败: {error}")))?; log::info!("旧 skills 表有 {old_count} 条记录"); let untrusted_rows = if context == MigrationRunContext::UntrustedRestore { let mut statement = conn .prepare( "SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory, app_type", ) .map_err(|error| { AppError::Database(format!("prepare typed v2 skills migration: {error}")) })?; let rows = statement .query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?, )) }) .map_err(|error| { AppError::Database(format!("read typed v2 skills migration: {error}")) })? .collect::, _>>() .map_err(|error| { AppError::Database(format!("decode typed v2 skills migration: {error}")) })?; for (directory, app_type, installed, _) in &rows { if directory.is_empty() { return Err(AppError::InvalidInput( "untrusted v2 skill has an empty directory".to_string(), )); } if !matches!(app_type.as_str(), "claude" | "codex" | "gemini") { return Err(AppError::InvalidInput(format!( "untrusted v2 skill {directory:?} has unsupported app_type {app_type:?}" ))); } Self::require_untrusted_integer_range( context, "v2->v3 skills typed mapping", "installed", *installed, 0..=1, )?; } rows } else { Vec::new() }; if context == MigrationRunContext::LocalUpgrade { 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 = stmt .query_map([], |row| { Ok(LegacySkillMigrationRow { directory: row.get(0)?, app_type: row.get(1)?, }) }) .map_err(|e| AppError::Database(format!("读取旧 skills 快照失败: {e}")))? .collect::, _>>() .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 目录导入。 conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')", [], ) .map_err(|error| AppError::Database(format!("写入 skills 迁移标记失败: {error}")))?; conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_snapshot', ?1)", [snapshot_json], ) .map_err(|error| AppError::Database(format!("写入 skills 迁移快照失败: {error}")))?; } // 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}")))?; if context == MigrationRunContext::UntrustedRestore { for (directory, app_type, installed, installed_at) in untrusted_rows { let (enabled_claude, enabled_codex, enabled_gemini) = match app_type.as_str() { "claude" => (installed, 0, 0), "codex" => (0, installed, 0), "gemini" => (0, 0, installed), _ => { return Err(AppError::InvalidInput(format!( "untrusted v2 skill has unsupported app_type {app_type:?}" ))) } }; let id = format!("{app_type}:{directory}"); conn.execute( "INSERT INTO skills ( id, name, directory, enabled_claude, enabled_codex, enabled_gemini, installed_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ id, directory, directory, enabled_claude, enabled_codex, enabled_gemini, installed_at ], ) .map_err(|error| { AppError::Database(format!("map v2 skill {id:?} into v3: {error}")) })?; } log::info!("v2 -> v3 untrusted skills mapped losslessly"); } else { log::info!( "skills 表已迁移到 v3 结构。\n\ 注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。" ); } Ok(()) } /// v3 -> v4 迁移:添加 OpenCode 支持 /// /// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。 fn migrate_v3_to_v4(conn: &Connection, _context: MigrationRunContext) -> 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, _context: MigrationRunContext) -> 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, context: MigrationRunContext) -> 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}")))?; if context == MigrationRunContext::UntrustedRestore { // Provider metadata is portable user data. The canonical-stage // provider decoder will reject malformed JSON; valid legacy values // are preserved byte-for-byte instead of normalized in scratch. return Ok(()); } // 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()))?; let mut meta = match serde_json::from_str::(&meta_str) { Ok(meta) => meta, Err(_) => continue, }; 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, _context: MigrationRunContext) -> 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, context: MigrationRunContext) -> 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}")))?; if context == MigrationRunContext::UntrustedRestore { // Pricing rows are portable data. Historical correction remains a // LocalUpgrade policy; the untrusted mapping is identity. return Ok(()); } // 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, context: MigrationRunContext) -> 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}")))?; if context == MigrationRunContext::UntrustedRestore { return Ok(()); } 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, _context: MigrationRunContext) -> 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, _context: MigrationRunContext, ) -> 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, _context: MigrationRunContext, ) -> 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, _context: MigrationRunContext, ) -> 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, context: MigrationRunContext) -> Result<(), AppError> { if !Self::table_exists(conn, "proxy_config")? { return Ok(()); } if context == MigrationRunContext::UntrustedRestore { let unsupported = conn .query_row( "SELECT app_type FROM proxy_config WHERE app_type NOT IN ('claude', 'codex', 'gemini') ORDER BY app_type LIMIT 1", [], |row| row.get::<_, String>(0), ) .optional() .map_err(|error| { AppError::InvalidInput(format!( "validate v13 proxy app domain before typed mapping: {error}" )) })?; if let Some(app_type) = unsupported { return Err(AppError::InvalidInput(format!( "untrusted v13 proxy row has unsupported app_type {app_type:?}" ))); } } 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)| -> Result { if Self::has_column(conn, "proxy_config", column)? { Ok(format!("\"{column}\"")) } else if context == MigrationRunContext::LocalUpgrade || column == "live_takeover_active" { Ok(fallback.into()) } else { Err(Self::untrusted_repair_error( "v13->v14 proxy rebuild", format!("proxy_config.{column} would use fallback {fallback}"), )) } }) .collect::, 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()))?; let insert_grokbuild = if context == MigrationRunContext::UntrustedRestore { conn.execute( "INSERT INTO proxy_config (app_type, created_at, updated_at) VALUES ('grokbuild', ?1, ?1)", [UNTRUSTED_MIGRATION_TIMESTAMP], ) } else { conn.execute( "INSERT INTO proxy_config (app_type) SELECT 'grokbuild' WHERE NOT EXISTS ( SELECT 1 FROM proxy_config WHERE app_type = 'grokbuild' )", [], ) }; insert_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, _context: MigrationRunContext, ) -> 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, context: MigrationRunContext) -> Result<(), AppError> { if context == MigrationRunContext::UntrustedRestore { // The reset is device/filesystem policy, not a portable schema // transformation. Preserve every incoming row here; the later // restore-policy layer independently excludes device-local cursor // state from publication. return Ok(()); } let codex_dir = crate::codex_config::get_codex_config_dir(); crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir) } fn create_v17_migration_endpoint_table( conn: &Connection, table_name: &str, ) -> Result<(), AppError> { Self::validate_identifier(table_name, "v17 migration endpoint table")?; conn.execute( &format!( "CREATE TABLE \"{table_name}\" ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, url TEXT NOT NULL, added_at INTEGER, last_used INTEGER, FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE, UNIQUE (provider_id, app_type, url) )" ), [], ) .map_err(|error| { AppError::Database(format!( "create v17 migration endpoint table '{table_name}': {error}" )) })?; Ok(()) } fn create_v17_migration_ledgers(conn: &Connection) -> Result<(), AppError> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS pi_provider_projections ( provider_id TEXT PRIMARY KEY, provider_key TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS skill_deployments ( app_type TEXT NOT NULL CHECK (app_type = 'pi'), skill_id TEXT NOT NULL, destination TEXT NOT NULL, destination_key TEXT NOT NULL, method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')), source_identity TEXT NOT NULL, deployed_digest TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (app_type, skill_id, destination_key), UNIQUE (app_type, destination_key) );", ) .map_err(|error| AppError::Database(format!("create v17 migration ledgers: {error}"))) } /// v16 -> v17: add the Pi desired bit, lossless endpoint metadata, and /// device-local ownership ledgers. No ownership is inferred during /// migration; both ledgers intentionally start empty. fn migrate_v16_to_v17(conn: &Connection, context: MigrationRunContext) -> Result<(), AppError> { if Self::table_exists(conn, "provider_endpoints")? { if context == MigrationRunContext::UntrustedRestore { let duplicate = conn .query_row( "SELECT provider_id, app_type, url, COUNT(*) FROM provider_endpoints GROUP BY provider_id, app_type, url HAVING COUNT(*) > 1 LIMIT 1", [], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, i64>(3)?, )) }, ) .optional() .map_err(|error| AppError::Database(error.to_string()))?; if let Some((provider_id, app_type, url, count)) = duplicate { return Err(AppError::InvalidInput(format!( "untrusted v16 restore contains {count} duplicate endpoint rows \ for ({provider_id}, {app_type}, {url}); migration repair is forbidden" ))); } } Self::add_column_if_missing(conn, "provider_endpoints", "last_used", "INTEGER")?; // Older builds allowed duplicate rows for one logical endpoint. // Merge their timestamps before rebuilding from the canonical // definition. The fixed-column copy makes FK/UNIQUE/collation // semantics part of migration rather than an optional index patch. if context == MigrationRunContext::LocalUpgrade { conn.execute_batch( "UPDATE provider_endpoints AS kept SET added_at = ( SELECT MIN(other.added_at) FROM provider_endpoints AS other WHERE other.provider_id = kept.provider_id AND other.app_type = kept.app_type AND other.url = kept.url ), last_used = ( SELECT MAX(other.last_used) FROM provider_endpoints AS other WHERE other.provider_id = kept.provider_id AND other.app_type = kept.app_type AND other.url = kept.url ) WHERE kept.id = ( SELECT MIN(other.id) FROM provider_endpoints AS other WHERE other.provider_id = kept.provider_id AND other.app_type = kept.app_type AND other.url = kept.url ); DELETE FROM provider_endpoints WHERE id NOT IN ( SELECT MIN(id) FROM provider_endpoints GROUP BY provider_id, app_type, url );", ) .map_err(|error| AppError::Database(error.to_string()))?; } const REBUILT_ENDPOINTS: &str = "provider_endpoints_v17_migration"; conn.execute(&format!("DROP TABLE IF EXISTS \"{REBUILT_ENDPOINTS}\""), []) .map_err(|error| AppError::Database(error.to_string()))?; Self::create_v17_migration_endpoint_table(conn, REBUILT_ENDPOINTS)?; conn.execute( &format!( "INSERT INTO \"{REBUILT_ENDPOINTS}\" (id, provider_id, app_type, url, added_at, last_used) SELECT id, provider_id, app_type, url, added_at, last_used FROM provider_endpoints" ), [], ) .map_err(|error| { AppError::Database(format!( "failed to copy provider_endpoints into canonical v17 table: {error}" )) })?; conn.execute("DROP TABLE provider_endpoints", []) .map_err(|error| AppError::Database(error.to_string()))?; conn.execute( &format!("ALTER TABLE \"{REBUILT_ENDPOINTS}\" RENAME TO provider_endpoints"), [], ) .map_err(|error| AppError::Database(error.to_string()))?; } else { Self::create_v17_migration_endpoint_table(conn, "provider_endpoints")?; } if Self::table_exists(conn, "skills")? { Self::add_column_if_missing( conn, "skills", "enabled_pi", "BOOLEAN NOT NULL DEFAULT 0", )?; } Self::create_v17_migration_ledgers(conn) } /// 插入默认模型定价数据 /// 格式: (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(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(与 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(list 价,与 Sonnet 4.6 一致;促销 $2/$10 至 2026-08-31 不入表) ( "claude-sonnet-5", "Claude Sonnet 5", "3", "15", "0.30", "3.75", ), // Claude 4.7 系列 ( "claude-opus-4-7", "Claude Opus 4.7", "5", "25", "0.50", "6.25", ), // Claude 4.6 系列 ( "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-5.6 系列(Sol / Terra / Luna,2026-06 发布) // 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列) ("gpt-5.6-sol", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), ( "gpt-5.6-terra", "GPT-5.6 Terra", "2.50", "15", "0.25", "3.125", ), ("gpt-5.6-luna", "GPT-5.6 Luna", "1", "6", "0.10", "1.25"), // 裸名 gpt-5.6 是 sol 的官方别名;effort 后缀对齐 gpt-5.5 系列的记账形态 ("gpt-5.6", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), ("gpt-5.6-low", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), ("gpt-5.6-medium", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), ("gpt-5.6-high", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), ("gpt-5.6-xhigh", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), ("gpt-5.6-minimal", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), // 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-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"), // Gemini 3.6 系列 ( "gemini-3.6-flash", "Gemini 3.6 Flash", "1.50", "7.50", "0.15", "0", ), // Gemini 3.5 系列 ( "gemini-3.5-flash", "Gemini 3.5 Flash", "1.50", "9.00", "0.15", "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 系列 ( "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-chat", "DeepSeek Chat", "0.27", "1.10", "0.07", "0", ), ( "deepseek-reasoner", "DeepSeek Reasoner", "0.55", "2.19", "0.14", "0", ), // DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算) ( "deepseek-v4-flash", "DeepSeek V4 Flash", "0.14", "0.28", "0.0028", "0", ), ( "deepseek-v4-pro", "DeepSeek V4 Pro", "0.435", "0.87", "0.003625", "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", ), ("kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "0"), // Kimi For Coding 套餐里 K3 的裸名(无 kimi- 前缀),同标准 list 价 ("k3", "Kimi K3", "3.00", "15.00", "0.30", "0"), // 腾讯混元 (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"), // MiniMax 系列 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( "minimax-m2.1-lightning", "MiniMax M2.1 Lightning", "0.27", "2.33", "0.03", "0", ), ("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"), ("minimax-m2.5", "MiniMax M2.5", "0.15", "0.95", "0.03", "0"), ( "minimax-m2.5-lightning", "MiniMax M2.5 Lightning", "0.30", "2.40", "0.03", "0", ), ( "minimax-m2.7", "MiniMax M2.7", "0.30", "1.20", "0.06", "0.375", ), ( "minimax-m2.7-highspeed", "MiniMax M2.7 Highspeed", "0.60", "2.40", "0.06", "0.375", ), ("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "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"), // 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.29", "0.0028", "0"), ( "mimo-v2.5-pro", "MiMo V2.5 Pro", "0.435", "0.87", "0.0036", "0", ), // Qwen 系列 (阿里巴巴) ("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"), ( "qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0.065", "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) ("grok-4.5", "Grok 4.5", "2", "6", "0.50", "0"), // Grok CLI 官方 OAuth 态 modelUsage 上报的内部别名。定价由 // costUsdTicks(1 tick = 1e-10 USD)双轮实测反推:input/output 与 // grok-4.5 同为 2/6,cache read 实际按 0.30 计(非 API 挂牌的 0.50) ("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", "0.55", "2.20", "0.55", "0"), ("o1", "OpenAI o1", "15", "60", "7.50", "0"), ("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"), ("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"), ("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"), ("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"), ]; 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-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", ), ]; 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 { 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 { 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 { 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 { 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::*; use serde_json::json; fn canonical_manifest_from_specs() -> serde_json::Value { let tables = CANONICAL_TABLE_SPECS .iter() .map(|spec| { json!({ "name": spec.name, "restoreClass": spec.restore_class, "columns": spec.columns.iter().map(|column| json!([ column.name, column.data_type, column.not_null, column.default, column.pk_position ])).collect::>(), "uniqueTuples": spec.unique_tuples.iter().map(|tuple| { tuple.columns.iter().map(|column| { json!([column.name, column.collation]) }).collect::>() }).collect::>(), "foreignKeys": spec.foreign_keys.iter().map(|foreign_key| json!({ "from": foreign_key.from, "table": foreign_key.table, "to": foreign_key.to, "onUpdate": foreign_key.on_update, "onDelete": foreign_key.on_delete, "match": foreign_key.match_type })).collect::>(), "checks": spec.invariants.iter().map(|invariant| invariant.name).collect::>() }) }) .collect::>(); json!({ "manifestVersion": 1, "schemaVersion": SCHEMA_VERSION, "codeAuthority": "src-tauri/src/database/schema.rs", "comparison": "semantic", "tables": tables, "futureUsageActivation": { "commit": 13, "proxy_request_logs.input_token_semantics": { "type": "INTEGER", "notNull": true, "default": null, "allowed": [1, 2, 3, 4] }, "usage_daily_rollups.input_token_semantics": { "type": "INTEGER", "notNull": true, "default": null, "allowed": [2] } } }) } #[test] fn canonical_schema_specs_match_review_manifest() -> Result<(), AppError> { let expected: serde_json::Value = serde_json::from_str(include_str!( "../../../tests/fixtures/pi/canonical-schema-manifest-v1.json" )) .expect("parse canonical schema manifest"); assert_eq!(canonical_manifest_from_specs(), expected); Ok(()) } #[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, MigrationRunContext::LocalUpgrade)?; 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, MigrationRunContext::LocalUpgrade)?; 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, MigrationRunContext::LocalUpgrade)?; 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 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, MigrationRunContext::LocalUpgrade)?; 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, MigrationRunContext::LocalUpgrade)?; 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, MigrationRunContext::LocalUpgrade)?; assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION); 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_adds_pi_ledgers_without_inferred_ownership() -> Result<(), AppError> { let conn = Connection::open_in_memory()?; conn.execute_batch( "CREATE TABLE providers ( id TEXT NOT NULL, app_type TEXT NOT NULL, PRIMARY KEY (id, app_type) ); CREATE TABLE provider_endpoints ( id INTEGER PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, url TEXT NOT NULL, added_at INTEGER ); CREATE TABLE skills ( id TEXT PRIMARY KEY, enabled_codex BOOLEAN NOT NULL DEFAULT 0 ); INSERT INTO providers (id, app_type) VALUES ('provider', 'pi'); INSERT INTO provider_endpoints (id, provider_id, app_type, url, added_at) VALUES (1, 'provider', 'pi', 'https://duplicate.test', 20), (2, 'provider', 'pi', 'https://duplicate.test', 10); INSERT INTO skills (id, enabled_codex) VALUES ('existing', 1);", )?; Database::set_user_version(&conn, 16)?; Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?; assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION); assert!(Database::has_column( &conn, "provider_endpoints", "last_used" )?); assert!(Database::has_column(&conn, "skills", "enabled_pi")?); assert!(Database::table_exists(&conn, "pi_provider_projections")?); assert!(Database::table_exists(&conn, "skill_deployments")?); let desired: i64 = conn.query_row( "SELECT enabled_pi FROM skills WHERE id = 'existing'", [], |row| row.get(0), )?; let ledgers: (i64, i64) = conn.query_row( "SELECT (SELECT COUNT(*) FROM pi_provider_projections), (SELECT COUNT(*) FROM skill_deployments)", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; assert_eq!(desired, 0); assert_eq!(ledgers, (0, 0)); let endpoint: (i64, Option) = conn.query_row( "SELECT COUNT(*), MIN(added_at) FROM provider_endpoints WHERE provider_id = 'provider' AND app_type = 'pi' AND url = 'https://duplicate.test'", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; assert_eq!(endpoint, (1, Some(10))); assert!(conn .execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES ('provider', 'pi', 'https://duplicate.test', 30)", [], ) .is_err()); Ok(()) } }