1use super::{lock_conn, Database, SCHEMA_VERSION};
6use crate::error::AppError;
7use rusqlite::{params, Connection};
8
9impl Database {
10 pub(crate) fn create_tables(&self) -> Result<(), AppError> {
12 let conn = lock_conn!(self.conn);
13 Self::create_tables_on_conn(&conn)
14 }
15
16 pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
18 conn.execute(
20 "CREATE TABLE IF NOT EXISTS providers (
21 id TEXT NOT NULL,
22 app_type TEXT NOT NULL,
23 name TEXT NOT NULL,
24 settings_config TEXT NOT NULL,
25 website_url TEXT,
26 category TEXT,
27 created_at INTEGER,
28 sort_index INTEGER,
29 notes TEXT,
30 icon TEXT,
31 icon_color TEXT,
32 meta TEXT NOT NULL DEFAULT '{}',
33 is_current BOOLEAN NOT NULL DEFAULT 0,
34 in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
35 PRIMARY KEY (id, app_type)
36 )",
37 [],
38 )
39 .map_err(|e| AppError::Database(e.to_string()))?;
40
41 conn.execute(
43 "CREATE TABLE IF NOT EXISTS provider_endpoints (
44 id INTEGER PRIMARY KEY AUTOINCREMENT,
45 provider_id TEXT NOT NULL,
46 app_type TEXT NOT NULL,
47 url TEXT NOT NULL,
48 added_at INTEGER,
49 FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
50 )",
51 [],
52 )
53 .map_err(|e| AppError::Database(e.to_string()))?;
54
55 conn.execute(
57 "CREATE TABLE IF NOT EXISTS mcp_servers (
58 id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
59 description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
60 enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
61 enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
62 enabled_hermes BOOLEAN NOT NULL DEFAULT 0
63 )",
64 [],
65 )
66 .map_err(|e| AppError::Database(e.to_string()))?;
67
68 conn.execute("CREATE TABLE IF NOT EXISTS prompts (
70 id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
71 description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
72 PRIMARY KEY (id, app_type)
73 )", []).map_err(|e| AppError::Database(e.to_string()))?;
74
75 conn.execute(
77 "CREATE TABLE IF NOT EXISTS skills (
78 id TEXT PRIMARY KEY,
79 name TEXT NOT NULL,
80 description TEXT,
81 directory TEXT NOT NULL,
82 repo_owner TEXT,
83 repo_name TEXT,
84 repo_branch TEXT DEFAULT 'main',
85 readme_url TEXT,
86 enabled_claude BOOLEAN NOT NULL DEFAULT 0,
87 enabled_codex BOOLEAN NOT NULL DEFAULT 0,
88 enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
89 enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
90 enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
91 installed_at INTEGER NOT NULL DEFAULT 0,
92 content_hash TEXT,
93 updated_at INTEGER NOT NULL DEFAULT 0
94 )",
95 [],
96 )
97 .map_err(|e| AppError::Database(e.to_string()))?;
98
99 conn.execute(
101 "CREATE TABLE IF NOT EXISTS skill_repos (
102 owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
103 enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
104 )",
105 [],
106 )
107 .map_err(|e| AppError::Database(e.to_string()))?;
108
109 conn.execute(
111 "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
112 [],
113 )
114 .map_err(|e| AppError::Database(e.to_string()))?;
115
116 conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
118 app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
119 proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
120 listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
121 enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
122 max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
123 streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
124 circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
125 circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
126 circuit_min_requests INTEGER NOT NULL DEFAULT 10,
127 default_cost_multiplier TEXT NOT NULL DEFAULT '1',
128 pricing_model_source TEXT NOT NULL DEFAULT 'response',
129 created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
130 )", []).map_err(|e| AppError::Database(e.to_string()))?;
131
132 if Self::has_column(conn, "proxy_config", "app_type")? {
138 conn.execute(
139 "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
140 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
141 circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
142 circuit_error_rate_threshold, circuit_min_requests)
143 VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
144 [],
145 )
146 .map_err(|e| AppError::Database(e.to_string()))?;
147 conn.execute(
148 "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
149 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
150 circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
151 circuit_error_rate_threshold, circuit_min_requests)
152 VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
153 [],
154 )
155 .map_err(|e| AppError::Database(e.to_string()))?;
156 conn.execute(
157 "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
158 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
159 circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
160 circuit_error_rate_threshold, circuit_min_requests)
161 VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
162 [],
163 )
164 .map_err(|e| AppError::Database(e.to_string()))?;
165 }
166
167 conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
169 provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
170 consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
171 last_error TEXT, updated_at TEXT NOT NULL,
172 PRIMARY KEY (provider_id, app_type),
173 FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
174 )", []).map_err(|e| AppError::Database(e.to_string()))?;
175
176 conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
178 request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
179 request_model TEXT,
180 input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
181 cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
182 input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
183 cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
184 total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
185 duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
186 provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
187 cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL,
188 data_source TEXT NOT NULL DEFAULT 'proxy'
189 )", []).map_err(|e| AppError::Database(e.to_string()))?;
190
191 conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
192 .map_err(|e| AppError::Database(e.to_string()))?;
193 conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
194 .map_err(|e| AppError::Database(e.to_string()))?;
195 conn.execute(
196 "CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
197 [],
198 )
199 .map_err(|e| AppError::Database(e.to_string()))?;
200 conn.execute(
201 "CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
202 [],
203 )
204 .map_err(|e| AppError::Database(e.to_string()))?;
205 conn.execute(
206 "CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
207 [],
208 )
209 .map_err(|e| AppError::Database(e.to_string()))?;
210
211 conn.execute(
213 "CREATE TABLE IF NOT EXISTS model_pricing (
214 model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
215 input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
216 cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
217 cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
218 )",
219 [],
220 )
221 .map_err(|e| AppError::Database(e.to_string()))?;
222
223 conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
225 id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
226 app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
227 response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
228 retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
229 )", []).map_err(|e| AppError::Database(e.to_string()))?;
230
231 conn.execute(
232 "CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
233 ON stream_check_logs(app_type, provider_id, tested_at DESC)",
234 [],
235 )
236 .map_err(|e| AppError::Database(e.to_string()))?;
237
238 conn.execute(
242 "CREATE TABLE IF NOT EXISTS proxy_live_backup (
243 app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
244 )",
245 [],
246 )
247 .map_err(|e| AppError::Database(e.to_string()))?;
248
249 conn.execute(
250 "CREATE TABLE IF NOT EXISTS usage_daily_rollups (
251 date TEXT NOT NULL,
252 app_type TEXT NOT NULL,
253 provider_id TEXT NOT NULL,
254 model TEXT NOT NULL,
255 request_count INTEGER NOT NULL DEFAULT 0,
256 success_count INTEGER NOT NULL DEFAULT 0,
257 input_tokens INTEGER NOT NULL DEFAULT 0,
258 output_tokens INTEGER NOT NULL DEFAULT 0,
259 cache_read_tokens INTEGER NOT NULL DEFAULT 0,
260 cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
261 total_cost_usd TEXT NOT NULL DEFAULT '0',
262 avg_latency_ms INTEGER NOT NULL DEFAULT 0,
263 PRIMARY KEY (date, app_type, provider_id, model)
264 )",
265 [],
266 )
267 .map_err(|e| AppError::Database(e.to_string()))?;
268
269 conn.execute(
270 "CREATE TABLE IF NOT EXISTS session_log_sync (
271 file_path TEXT PRIMARY KEY,
272 last_modified INTEGER NOT NULL,
273 last_line_offset INTEGER NOT NULL DEFAULT 0,
274 last_synced_at INTEGER NOT NULL
275 )",
276 [],
277 )
278 .map_err(|e| AppError::Database(e.to_string()))?;
279
280 let _ = conn.execute(
282 "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
283 [],
284 );
285
286 let _ = conn.execute(
288 "ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
289 [],
290 );
291 let _ = conn.execute(
292 "ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
293 [],
294 );
295 let _ = conn.execute(
296 "ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
297 [],
298 );
299 let _ = conn.execute(
300 "ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
301 [],
302 );
303
304 let _ = conn.execute(
306 "ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
307 [],
308 );
309 let _ = conn.execute(
310 "ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
311 [],
312 );
313 let _ = conn.execute(
314 "ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
315 [],
316 );
317
318 if Self::table_exists(conn, "proxy_config")?
321 && !Self::has_column(conn, "proxy_config", "app_type")?
322 {
323 Self::migrate_proxy_config_to_per_app(conn)?;
324 }
325
326 Self::add_column_if_missing(
328 conn,
329 "providers",
330 "in_failover_queue",
331 "BOOLEAN NOT NULL DEFAULT 0",
332 )?;
333
334 let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
336 let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
337
338 let _ = conn.execute(
340 "CREATE INDEX IF NOT EXISTS idx_providers_failover
341 ON providers(app_type, in_failover_queue, sort_index)",
342 [],
343 );
344
345 Ok(())
346 }
347
348 pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
350 let conn = lock_conn!(self.conn);
351 Self::apply_schema_migrations_on_conn(&conn)
352 }
353
354 pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
356 conn.execute("SAVEPOINT schema_migration;", [])
357 .map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
358
359 let mut version = Self::get_user_version(conn)?;
360
361 if version > SCHEMA_VERSION {
362 conn.execute("ROLLBACK TO schema_migration;", []).ok();
363 conn.execute("RELEASE schema_migration;", []).ok();
364 return Err(AppError::Database(format!(
365 "数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。"
366 )));
367 }
368
369 let result = (|| {
370 while version < SCHEMA_VERSION {
371 match version {
372 0 => {
373 log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
374 Self::migrate_v0_to_v1(conn)?;
375 Self::set_user_version(conn, 1)?;
376 }
377 1 => {
378 log::info!(
379 "迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
380 );
381 Self::migrate_v1_to_v2(conn)?;
382 Self::set_user_version(conn, 2)?;
383 }
384 2 => {
385 log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)");
386 Self::migrate_v2_to_v3(conn)?;
387 Self::set_user_version(conn, 3)?;
388 }
389 3 => {
390 log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
391 Self::migrate_v3_to_v4(conn)?;
392 Self::set_user_version(conn, 4)?;
393 }
394 4 => {
395 log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
396 Self::migrate_v4_to_v5(conn)?;
397 Self::set_user_version(conn, 5)?;
398 }
399 5 => {
400 log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一)");
401 Self::migrate_v5_to_v6(conn)?;
402 Self::set_user_version(conn, 6)?;
403 }
404 6 => {
405 log::info!("迁移数据库从 v6 到 v7(Skills 更新检测支持)");
406 Self::migrate_v6_to_v7(conn)?;
407 Self::set_user_version(conn, 7)?;
408 }
409 7 => {
410 log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)");
411 Self::migrate_v7_to_v8(conn)?;
412 Self::set_user_version(conn, 8)?;
413 }
414 8 => {
415 log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
416 Self::migrate_v8_to_v9(conn)?;
417 Self::set_user_version(conn, 9)?;
418 }
419 9 => {
420 log::info!("迁移数据库从 v9 到 v10(添加 Hermes Agent 支持)");
421 Self::migrate_v9_to_v10(conn)?;
422 Self::set_user_version(conn, 10)?;
423 }
424 _ => {
425 return Err(AppError::Database(format!(
426 "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
427 )));
428 }
429 }
430 version = Self::get_user_version(conn)?;
431 }
432 Ok(())
433 })();
434
435 match result {
436 Ok(_) => {
437 conn.execute("RELEASE schema_migration;", [])
438 .map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?;
439 Ok(())
440 }
441 Err(e) => {
442 conn.execute("ROLLBACK TO schema_migration;", []).ok();
443 conn.execute("RELEASE schema_migration;", []).ok();
444 Err(e)
445 }
446 }
447 }
448
449 fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {
451 Self::add_column_if_missing(conn, "providers", "category", "TEXT")?;
453 Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?;
454 Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?;
455 Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?;
456 Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?;
457 Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?;
458 Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?;
459 Self::add_column_if_missing(
460 conn,
461 "providers",
462 "is_current",
463 "BOOLEAN NOT NULL DEFAULT 0",
464 )?;
465
466 Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?;
468
469 Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?;
471 Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?;
472 Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?;
473 Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?;
474 Self::add_column_if_missing(
475 conn,
476 "mcp_servers",
477 "enabled_codex",
478 "BOOLEAN NOT NULL DEFAULT 0",
479 )?;
480 Self::add_column_if_missing(
481 conn,
482 "mcp_servers",
483 "enabled_gemini",
484 "BOOLEAN NOT NULL DEFAULT 0",
485 )?;
486
487 Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
489 Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
490 Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
491 Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
492
493 Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
495
496 Self::add_column_if_missing(
498 conn,
499 "skill_repos",
500 "branch",
501 "TEXT NOT NULL DEFAULT 'main'",
502 )?;
503 Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
504 Ok(())
507 }
508
509 fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
511 Self::add_column_if_missing(
513 conn,
514 "providers",
515 "cost_multiplier",
516 "TEXT NOT NULL DEFAULT '1.0'",
517 )?;
518 Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
519 Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
520 Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
521 Self::add_column_if_missing(
522 conn,
523 "providers",
524 "in_failover_queue",
525 "BOOLEAN NOT NULL DEFAULT 0",
526 )?;
527
528 if Self::table_exists(conn, "proxy_config")? {
530 Self::add_column_if_missing(
532 conn,
533 "proxy_config",
534 "proxy_enabled",
535 "INTEGER NOT NULL DEFAULT 0",
536 )?;
537 Self::add_column_if_missing(
538 conn,
539 "proxy_config",
540 "listen_address",
541 "TEXT NOT NULL DEFAULT '127.0.0.1'",
542 )?;
543 Self::add_column_if_missing(
544 conn,
545 "proxy_config",
546 "listen_port",
547 "INTEGER NOT NULL DEFAULT 15721",
548 )?;
549 Self::add_column_if_missing(
550 conn,
551 "proxy_config",
552 "enable_logging",
553 "INTEGER NOT NULL DEFAULT 1",
554 )?;
555
556 Self::add_column_if_missing(
557 conn,
558 "proxy_config",
559 "streaming_first_byte_timeout",
560 "INTEGER NOT NULL DEFAULT 60",
561 )?;
562 Self::add_column_if_missing(
563 conn,
564 "proxy_config",
565 "streaming_idle_timeout",
566 "INTEGER NOT NULL DEFAULT 120",
567 )?;
568 Self::add_column_if_missing(
569 conn,
570 "proxy_config",
571 "non_streaming_timeout",
572 "INTEGER NOT NULL DEFAULT 600",
573 )?;
574 }
575
576 conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
578 .map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
579 conn.execute("DROP TABLE IF EXISTS failover_queue", [])
580 .map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
581
582 conn.execute(
584 "CREATE INDEX IF NOT EXISTS idx_providers_failover
585 ON providers(app_type, in_failover_queue, sort_index)",
586 [],
587 )
588 .map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
589
590 conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
592 request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
593 request_model TEXT,
594 input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
595 cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
596 input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
597 cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
598 total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
599 duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
600 provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
601 cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
602 )", [])?;
603
604 Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
606 Self::add_column_if_missing(
607 conn,
608 "proxy_request_logs",
609 "is_streaming",
610 "INTEGER NOT NULL DEFAULT 0",
611 )?;
612 Self::add_column_if_missing(
613 conn,
614 "proxy_request_logs",
615 "cost_multiplier",
616 "TEXT NOT NULL DEFAULT '1.0'",
617 )?;
618 Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
619 Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
620
621 conn.execute(
623 "CREATE TABLE IF NOT EXISTS model_pricing (
624 model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
625 input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
626 cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
627 cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
628 )",
629 [],
630 )?;
631
632 conn.execute("DELETE FROM model_pricing", [])
634 .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
635 Self::seed_model_pricing(conn)?;
636
637 Self::migrate_skills_table(conn)?;
639
640 Self::migrate_proxy_config_to_per_app(conn)?;
642
643 Ok(())
644 }
645
646 fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
648 if !Self::table_exists(conn, "proxy_config")? {
650 return Ok(());
652 }
653
654 if Self::has_column(conn, "proxy_config", "app_type")? {
655 log::info!("proxy_config 已经是三行结构,跳过迁移");
657 return Ok(());
658 }
659
660 let old_config = conn
662 .query_row(
663 "SELECT listen_address, listen_port, max_retries, enable_logging,
664 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
665 FROM proxy_config WHERE id = 1",
666 [],
667 |row| {
668 Ok((
669 row.get::<_, String>(0)?,
670 row.get::<_, i32>(1)?,
671 row.get::<_, i32>(2)?,
672 row.get::<_, i32>(3)?,
673 row.get::<_, i32>(4).unwrap_or(30),
674 row.get::<_, i32>(5).unwrap_or(60),
675 row.get::<_, i32>(6).unwrap_or(300),
676 ))
677 },
678 )
679 .unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
680
681 let old_cb = conn.query_row(
682 "SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
683 FROM circuit_breaker_config WHERE id = 1", [],
684 |row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
685 row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
686 ).unwrap_or((5, 2, 60, 0.5, 10));
687
688 let get_bool = |key: &str| -> bool {
689 conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
690 r.get::<_, String>(0)
691 })
692 .map(|v| v == "true" || v == "1")
693 .unwrap_or(false)
694 };
695
696 let apps = [
697 (
698 "claude",
699 get_bool("proxy_takeover_claude"),
700 get_bool("auto_failover_enabled_claude"),
701 6,
702 45,
703 90,
704 8,
705 3,
706 90,
707 0.6,
708 15,
709 ),
710 (
711 "codex",
712 get_bool("proxy_takeover_codex"),
713 get_bool("auto_failover_enabled_codex"),
714 3,
715 old_config.4,
716 old_config.5,
717 old_cb.0,
718 old_cb.1,
719 old_cb.2,
720 old_cb.3,
721 old_cb.4,
722 ),
723 (
724 "gemini",
725 get_bool("proxy_takeover_gemini"),
726 get_bool("auto_failover_enabled_gemini"),
727 5,
728 old_config.4,
729 old_config.5,
730 old_cb.0,
731 old_cb.1,
732 old_cb.2,
733 old_cb.3,
734 old_cb.4,
735 ),
736 ];
737
738 conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
740 conn.execute("CREATE TABLE proxy_config_new (
741 app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
742 proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
743 listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
744 enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
745 max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
746 streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
747 circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
748 circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
749 circuit_min_requests INTEGER NOT NULL DEFAULT 10,
750 default_cost_multiplier TEXT NOT NULL DEFAULT '1',
751 pricing_model_source TEXT NOT NULL DEFAULT 'response',
752 created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
753 )", [])?;
754
755 for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
757 conn.execute(
758 "INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
759 enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
760 non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
761 circuit_error_rate_threshold, circuit_min_requests)
762 VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
763 rusqlite::params![app, old_config.0, old_config.1, old_config.3,
764 if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
765 retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
766 ).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
767 }
768
769 conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
771 conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
772 conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
773 conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
774 conn.execute(
775 "DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
776 [],
777 )?;
778
779 log::info!("proxy_config 已迁移为三行结构");
780 Ok(())
781 }
782
783 fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
785 if Self::has_column(conn, "skills", "enabled_claude")?
790 || Self::has_column(conn, "skills", "id")?
791 {
792 log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移");
793 return Ok(());
794 }
795
796 if Self::has_column(conn, "skills", "app_type")? {
798 log::info!("skills 表已经包含 app_type 字段,跳过迁移");
799 return Ok(());
800 }
801
802 log::info!("开始迁移 skills 表...");
803
804 conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
806 .map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
807
808 conn.execute(
810 "CREATE TABLE skills (
811 directory TEXT NOT NULL,
812 app_type TEXT NOT NULL,
813 installed BOOLEAN NOT NULL DEFAULT 0,
814 installed_at INTEGER NOT NULL DEFAULT 0,
815 PRIMARY KEY (directory, app_type)
816 )",
817 [],
818 )
819 .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
820
821 let mut stmt = conn
824 .prepare("SELECT key, installed, installed_at FROM skills_old")
825 .map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
826
827 let old_skills: Vec<(String, bool, i64)> = stmt
828 .query_map([], |row| {
829 Ok((
830 row.get::<_, String>(0)?,
831 row.get::<_, bool>(1)?,
832 row.get::<_, i64>(2)?,
833 ))
834 })
835 .map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
836 .collect::<Result<Vec<_>, _>>()
837 .map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
838
839 let count = old_skills.len();
840
841 for (key, installed, installed_at) in old_skills {
842 let (app_type, directory) = if let Some(idx) = key.find(':') {
844 let (app, dir) = key.split_at(idx);
845 (app.to_string(), dir[1..].to_string()) } else {
847 ("claude".to_string(), key.clone())
848 };
849
850 conn.execute(
851 "INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
852 rusqlite::params![directory, app_type, installed, installed_at],
853 )
854 .map_err(|e| {
855 AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
856 })?;
857 }
858
859 conn.execute("DROP TABLE skills_old", [])
861 .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
862
863 log::info!("skills 表迁移完成,共迁移 {count} 条记录");
864 Ok(())
865 }
866
867 fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
876 if Self::has_column(conn, "skills", "enabled_claude")? {
878 log::info!("skills 表已经是 v3 结构,跳过迁移");
879 return Ok(());
880 }
881
882 log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
883
884 let old_count: i64 = conn
886 .query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
887 .unwrap_or(0);
888 log::info!("旧 skills 表有 {old_count} 条记录");
889
890 let _ = conn.execute(
894 "INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
895 [],
896 );
897
898 conn.execute("DROP TABLE IF EXISTS skills", [])
900 .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
901
902 conn.execute(
904 "CREATE TABLE skills (
905 id TEXT PRIMARY KEY,
906 name TEXT NOT NULL,
907 description TEXT,
908 directory TEXT NOT NULL,
909 repo_owner TEXT,
910 repo_name TEXT,
911 repo_branch TEXT DEFAULT 'main',
912 readme_url TEXT,
913 enabled_claude BOOLEAN NOT NULL DEFAULT 0,
914 enabled_codex BOOLEAN NOT NULL DEFAULT 0,
915 enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
916 installed_at INTEGER NOT NULL DEFAULT 0
917 )",
918 [],
919 )
920 .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
921
922 log::info!(
923 "skills 表已迁移到 v3 结构。\n\
924 注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。"
925 );
926
927 Ok(())
928 }
929
930 fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
934 Self::add_column_if_missing(
936 conn,
937 "mcp_servers",
938 "enabled_opencode",
939 "BOOLEAN NOT NULL DEFAULT 0",
940 )?;
941
942 Self::add_column_if_missing(
944 conn,
945 "skills",
946 "enabled_opencode",
947 "BOOLEAN NOT NULL DEFAULT 0",
948 )?;
949
950 log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
951 Ok(())
952 }
953
954 fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
956 if Self::table_exists(conn, "proxy_config")? {
957 Self::add_column_if_missing(
958 conn,
959 "proxy_config",
960 "default_cost_multiplier",
961 "TEXT NOT NULL DEFAULT '1'",
962 )?;
963 Self::add_column_if_missing(
964 conn,
965 "proxy_config",
966 "pricing_model_source",
967 "TEXT NOT NULL DEFAULT 'response'",
968 )?;
969 }
970 if Self::table_exists(conn, "proxy_request_logs")? {
971 Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
972 }
973
974 log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
975 Ok(())
976 }
977
978 fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
979 conn.execute(
980 "CREATE TABLE IF NOT EXISTS usage_daily_rollups (
981 date TEXT NOT NULL,
982 app_type TEXT NOT NULL,
983 provider_id TEXT NOT NULL,
984 model TEXT NOT NULL,
985 request_count INTEGER NOT NULL DEFAULT 0,
986 success_count INTEGER NOT NULL DEFAULT 0,
987 input_tokens INTEGER NOT NULL DEFAULT 0,
988 output_tokens INTEGER NOT NULL DEFAULT 0,
989 cache_read_tokens INTEGER NOT NULL DEFAULT 0,
990 cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
991 total_cost_usd TEXT NOT NULL DEFAULT '0',
992 avg_latency_ms INTEGER NOT NULL DEFAULT 0,
993 PRIMARY KEY (date, app_type, provider_id, model)
994 )",
995 [],
996 )
997 .map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
998
999 let mut stmt = conn
1000 .prepare("SELECT id, app_type, meta FROM providers")
1001 .map_err(|e| AppError::Database(e.to_string()))?;
1002
1003 let rows = stmt
1004 .query_map([], |row| {
1005 Ok((
1006 row.get::<_, String>(0)?,
1007 row.get::<_, String>(1)?,
1008 row.get::<_, String>(2)?,
1009 ))
1010 })
1011 .map_err(|e| AppError::Database(e.to_string()))?;
1012
1013 let mut updates = Vec::new();
1014 for row in rows {
1015 let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?;
1016
1017 if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
1018 let mut updated = false;
1019
1020 if let Some(usage_script) = meta.get_mut("usage_script") {
1021 if let Some(template_type) = usage_script.get_mut("template_type") {
1022 if template_type == "copilot" {
1023 *template_type =
1024 serde_json::Value::String("github_copilot".to_string());
1025 updated = true;
1026 }
1027 }
1028 }
1029
1030 if updated {
1031 let new_meta_str = serde_json::to_string(&meta)
1032 .map_err(|e| AppError::Database(e.to_string()))?;
1033 updates.push((id, app_type, new_meta_str));
1034 }
1035 }
1036 }
1037
1038 for (id, app_type, new_meta) in updates {
1039 conn.execute(
1040 "UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3",
1041 params![new_meta, id, app_type],
1042 )
1043 .map_err(|e| AppError::Database(e.to_string()))?;
1044 }
1045
1046 log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表,统一 copilot 模板类型");
1047 Ok(())
1048 }
1049
1050 fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> {
1051 if Self::table_exists(conn, "skills")? {
1052 Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?;
1053 Self::add_column_if_missing(
1054 conn,
1055 "skills",
1056 "updated_at",
1057 "INTEGER NOT NULL DEFAULT 0",
1058 )?;
1059 }
1060
1061 log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列");
1062 Ok(())
1063 }
1064
1065 fn migrate_v7_to_v8(conn: &Connection) -> Result<(), AppError> {
1066 if Self::table_exists(conn, "proxy_request_logs")? {
1067 Self::add_column_if_missing(
1068 conn,
1069 "proxy_request_logs",
1070 "data_source",
1071 "TEXT NOT NULL DEFAULT 'proxy'",
1072 )?;
1073 }
1074
1075 conn.execute(
1076 "CREATE TABLE IF NOT EXISTS session_log_sync (
1077 file_path TEXT PRIMARY KEY,
1078 last_modified INTEGER NOT NULL,
1079 last_line_offset INTEGER NOT NULL DEFAULT 0,
1080 last_synced_at INTEGER NOT NULL
1081 )",
1082 [],
1083 )
1084 .map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?;
1085
1086 if Self::table_exists(conn, "model_pricing")? {
1087 let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[
1088 ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"),
1089 ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"),
1090 ("deepseek-v3", "0.28", "1.11", "0.028", "0"),
1091 ("doubao-seed-code", "0.17", "1.11", "0.02", "0"),
1092 ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"),
1093 ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"),
1094 ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"),
1095 ("minimax-m2.1", "0.27", "0.95", "0.03", "0"),
1096 ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"),
1097 ("minimax-m2", "0.27", "0.95", "0.03", "0"),
1098 ("glm-4.7", "0.39", "1.75", "0.04", "0"),
1099 ("glm-4.6", "0.28", "1.11", "0.03", "0"),
1100 ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"),
1101 ];
1102
1103 for (model_id, input, output, cache_read, cache_creation) in pricing_fixes {
1104 conn.execute(
1105 "UPDATE model_pricing SET
1106 input_cost_per_million = ?2,
1107 output_cost_per_million = ?3,
1108 cache_read_cost_per_million = ?4,
1109 cache_creation_cost_per_million = ?5
1110 WHERE model_id = ?1",
1111 params![model_id, input, output, cache_read, cache_creation],
1112 )
1113 .map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?;
1114 }
1115 }
1116
1117 log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价");
1118 Ok(())
1119 }
1120
1121 fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
1123 conn.execute(
1124 "CREATE TABLE IF NOT EXISTS model_pricing (
1125 model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
1126 input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
1127 cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
1128 cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
1129 )",
1130 [],
1131 )
1132 .map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
1133 conn.execute("DELETE FROM model_pricing", [])
1134 .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
1135 Self::seed_model_pricing(conn)?;
1136 log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
1137 Ok(())
1138 }
1139
1140 fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> {
1142 Self::add_column_if_missing(
1143 conn,
1144 "mcp_servers",
1145 "enabled_hermes",
1146 "BOOLEAN NOT NULL DEFAULT 0",
1147 )?;
1148
1149 if Self::table_exists(conn, "skills")? {
1150 Self::add_column_if_missing(
1151 conn,
1152 "skills",
1153 "enabled_hermes",
1154 "BOOLEAN NOT NULL DEFAULT 0",
1155 )?;
1156 }
1157
1158 log::info!("v9 -> v10 迁移完成:已添加 Hermes Agent 支持");
1159 Ok(())
1160 }
1161
1162 fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
1166 let pricing_data = [
1167 (
1169 "claude-opus-4-7",
1170 "Claude Opus 4.7",
1171 "5",
1172 "25",
1173 "0.50",
1174 "6.25",
1175 ),
1176 (
1178 "claude-opus-4-6-20260206",
1179 "Claude Opus 4.6",
1180 "5",
1181 "25",
1182 "0.50",
1183 "6.25",
1184 ),
1185 (
1186 "claude-sonnet-4-6-20260217",
1187 "Claude Sonnet 4.6",
1188 "3",
1189 "15",
1190 "0.30",
1191 "3.75",
1192 ),
1193 (
1195 "claude-opus-4-5-20251101",
1196 "Claude Opus 4.5",
1197 "5",
1198 "25",
1199 "0.50",
1200 "6.25",
1201 ),
1202 (
1203 "claude-sonnet-4-5-20250929",
1204 "Claude Sonnet 4.5",
1205 "3",
1206 "15",
1207 "0.30",
1208 "3.75",
1209 ),
1210 (
1211 "claude-haiku-4-5-20251001",
1212 "Claude Haiku 4.5",
1213 "1",
1214 "5",
1215 "0.10",
1216 "1.25",
1217 ),
1218 (
1220 "claude-opus-4-20250514",
1221 "Claude Opus 4",
1222 "15",
1223 "75",
1224 "1.50",
1225 "18.75",
1226 ),
1227 (
1228 "claude-opus-4-1-20250805",
1229 "Claude Opus 4.1",
1230 "15",
1231 "75",
1232 "1.50",
1233 "18.75",
1234 ),
1235 (
1236 "claude-sonnet-4-20250514",
1237 "Claude Sonnet 4",
1238 "3",
1239 "15",
1240 "0.30",
1241 "3.75",
1242 ),
1243 (
1245 "claude-3-5-haiku-20241022",
1246 "Claude 3.5 Haiku",
1247 "0.80",
1248 "4",
1249 "0.08",
1250 "1",
1251 ),
1252 (
1253 "claude-3-5-sonnet-20241022",
1254 "Claude 3.5 Sonnet",
1255 "3",
1256 "15",
1257 "0.30",
1258 "3.75",
1259 ),
1260 ("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
1262 ("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
1263 ("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"),
1264 ("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
1266 ("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
1267 ("gpt-5.2-medium", "GPT-5.2", "1.75", "14", "0.175", "0"),
1268 ("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"),
1269 ("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"),
1270 ("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
1271 (
1272 "gpt-5.2-codex-low",
1273 "GPT-5.2 Codex",
1274 "1.75",
1275 "14",
1276 "0.175",
1277 "0",
1278 ),
1279 (
1280 "gpt-5.2-codex-medium",
1281 "GPT-5.2 Codex",
1282 "1.75",
1283 "14",
1284 "0.175",
1285 "0",
1286 ),
1287 (
1288 "gpt-5.2-codex-high",
1289 "GPT-5.2 Codex",
1290 "1.75",
1291 "14",
1292 "0.175",
1293 "0",
1294 ),
1295 (
1296 "gpt-5.2-codex-xhigh",
1297 "GPT-5.2 Codex",
1298 "1.75",
1299 "14",
1300 "0.175",
1301 "0",
1302 ),
1303 ("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
1305 (
1306 "gpt-5.3-codex-low",
1307 "GPT-5.3 Codex",
1308 "1.75",
1309 "14",
1310 "0.175",
1311 "0",
1312 ),
1313 (
1314 "gpt-5.3-codex-medium",
1315 "GPT-5.3 Codex",
1316 "1.75",
1317 "14",
1318 "0.175",
1319 "0",
1320 ),
1321 (
1322 "gpt-5.3-codex-high",
1323 "GPT-5.3 Codex",
1324 "1.75",
1325 "14",
1326 "0.175",
1327 "0",
1328 ),
1329 (
1330 "gpt-5.3-codex-xhigh",
1331 "GPT-5.3 Codex",
1332 "1.75",
1333 "14",
1334 "0.175",
1335 "0",
1336 ),
1337 ("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
1339 ("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
1340 ("gpt-5.1-medium", "GPT-5.1", "1.25", "10", "0.125", "0"),
1341 ("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"),
1342 ("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"),
1343 ("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
1344 (
1345 "gpt-5.1-codex-mini",
1346 "GPT-5.1 Codex",
1347 "1.25",
1348 "10",
1349 "0.125",
1350 "0",
1351 ),
1352 (
1353 "gpt-5.1-codex-max",
1354 "GPT-5.1 Codex",
1355 "1.25",
1356 "10",
1357 "0.125",
1358 "0",
1359 ),
1360 (
1361 "gpt-5.1-codex-max-high",
1362 "GPT-5.1 Codex",
1363 "1.25",
1364 "10",
1365 "0.125",
1366 "0",
1367 ),
1368 (
1369 "gpt-5.1-codex-max-xhigh",
1370 "GPT-5.1 Codex",
1371 "1.25",
1372 "10",
1373 "0.125",
1374 "0",
1375 ),
1376 ("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
1378 ("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"),
1379 ("gpt-5-medium", "GPT-5", "1.25", "10", "0.125", "0"),
1380 ("gpt-5-high", "GPT-5", "1.25", "10", "0.125", "0"),
1381 ("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"),
1382 ("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
1383 ("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
1384 (
1385 "gpt-5-codex-medium",
1386 "GPT-5 Codex",
1387 "1.25",
1388 "10",
1389 "0.125",
1390 "0",
1391 ),
1392 (
1393 "gpt-5-codex-high",
1394 "GPT-5 Codex",
1395 "1.25",
1396 "10",
1397 "0.125",
1398 "0",
1399 ),
1400 (
1401 "gpt-5-codex-mini",
1402 "GPT-5 Codex",
1403 "1.25",
1404 "10",
1405 "0.125",
1406 "0",
1407 ),
1408 (
1409 "gpt-5-codex-mini-medium",
1410 "GPT-5 Codex",
1411 "1.25",
1412 "10",
1413 "0.125",
1414 "0",
1415 ),
1416 (
1417 "gpt-5-codex-mini-high",
1418 "GPT-5 Codex",
1419 "1.25",
1420 "10",
1421 "0.125",
1422 "0",
1423 ),
1424 ("o3", "OpenAI o3", "2", "8", "0.50", "0"),
1426 ("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"),
1427 ("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
1429 ("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
1430 ("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
1431 (
1433 "gemini-3.1-pro-preview",
1434 "Gemini 3.1 Pro Preview",
1435 "2",
1436 "12",
1437 "0.20",
1438 "0",
1439 ),
1440 (
1441 "gemini-3.1-flash-lite-preview",
1442 "Gemini 3.1 Flash Lite Preview",
1443 "0.25",
1444 "1.50",
1445 "0.025",
1446 "0",
1447 ),
1448 (
1450 "gemini-3-pro-preview",
1451 "Gemini 3 Pro Preview",
1452 "2",
1453 "12",
1454 "0.2",
1455 "0",
1456 ),
1457 (
1458 "gemini-3-flash-preview",
1459 "Gemini 3 Flash Preview",
1460 "0.5",
1461 "3",
1462 "0.05",
1463 "0",
1464 ),
1465 (
1467 "gemini-2.5-pro",
1468 "Gemini 2.5 Pro",
1469 "1.25",
1470 "10",
1471 "0.125",
1472 "0",
1473 ),
1474 (
1475 "gemini-2.5-flash",
1476 "Gemini 2.5 Flash",
1477 "0.3",
1478 "2.5",
1479 "0.03",
1480 "0",
1481 ),
1482 (
1483 "gemini-2.5-flash-lite",
1484 "Gemini 2.5 Flash Lite",
1485 "0.10",
1486 "0.40",
1487 "0.01",
1488 "0",
1489 ),
1490 (
1492 "gemini-2.0-flash",
1493 "Gemini 2.0 Flash",
1494 "0.10",
1495 "0.40",
1496 "0.025",
1497 "0",
1498 ),
1499 (
1501 "step-3.5-flash",
1502 "Step 3.5 Flash",
1503 "0.10",
1504 "0.30",
1505 "0.02",
1506 "0",
1507 ),
1508 (
1511 "doubao-seed-code",
1512 "Doubao Seed Code",
1513 "0.17",
1514 "1.11",
1515 "0.02",
1516 "0",
1517 ),
1518 (
1519 "doubao-seed-2-0-pro",
1520 "Doubao Seed 2.0 Pro",
1521 "0.47",
1522 "2.37",
1523 "0",
1524 "0",
1525 ),
1526 (
1527 "doubao-seed-2-0-code",
1528 "Doubao Seed 2.0 Code",
1529 "0.47",
1530 "2.37",
1531 "0",
1532 "0",
1533 ),
1534 (
1535 "doubao-seed-2-0-lite",
1536 "Doubao Seed 2.0 Lite",
1537 "0.25",
1538 "2",
1539 "0",
1540 "0",
1541 ),
1542 (
1543 "doubao-seed-2-0-mini",
1544 "Doubao Seed 2.0 Mini",
1545 "0.03",
1546 "0.31",
1547 "0",
1548 "0",
1549 ),
1550 (
1552 "deepseek-v3.2",
1553 "DeepSeek V3.2",
1554 "0.28",
1555 "0.42",
1556 "0.028",
1557 "0",
1558 ),
1559 (
1560 "deepseek-v3.1",
1561 "DeepSeek V3.1",
1562 "0.55",
1563 "1.67",
1564 "0.055",
1565 "0",
1566 ),
1567 ("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
1568 (
1569 "deepseek-chat",
1570 "DeepSeek Chat",
1571 "0.27",
1572 "1.10",
1573 "0.07",
1574 "0",
1575 ),
1576 (
1577 "deepseek-reasoner",
1578 "DeepSeek Reasoner",
1579 "0.55",
1580 "2.19",
1581 "0.14",
1582 "0",
1583 ),
1584 (
1586 "kimi-k2-thinking",
1587 "Kimi K2 Thinking",
1588 "0.55",
1589 "2.20",
1590 "0.10",
1591 "0",
1592 ),
1593 ("kimi-k2-0905", "Kimi K2", "0.55", "2.20", "0.10", "0"),
1594 (
1595 "kimi-k2-turbo",
1596 "Kimi K2 Turbo",
1597 "1.11",
1598 "8.06",
1599 "0.14",
1600 "0",
1601 ),
1602 ("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
1603 ("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
1604 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
1606 (
1607 "minimax-m2.1-lightning",
1608 "MiniMax M2.1 Lightning",
1609 "0.27",
1610 "2.33",
1611 "0.03",
1612 "0",
1613 ),
1614 ("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
1615 ("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
1616 (
1617 "minimax-m2.5-lightning",
1618 "MiniMax M2.5 Lightning",
1619 "0.30",
1620 "2.40",
1621 "0.03",
1622 "0",
1623 ),
1624 (
1625 "minimax-m2.7",
1626 "MiniMax M2.7",
1627 "0.30",
1628 "1.20",
1629 "0.06",
1630 "0.375",
1631 ),
1632 (
1633 "minimax-m2.7-highspeed",
1634 "MiniMax M2.7 Highspeed",
1635 "0.60",
1636 "2.40",
1637 "0.06",
1638 "0.375",
1639 ),
1640 ("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
1642 ("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
1643 ("glm-5", "GLM-5", "0.72", "2.30", "0", "0"),
1644 ("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"),
1645 (
1647 "mimo-v2-flash",
1648 "Mimo V2 Flash",
1649 "0.09",
1650 "0.29",
1651 "0.009",
1652 "0",
1653 ),
1654 ("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
1655 ("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
1657 ("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
1658 ("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
1659 (
1660 "qwen3-235b-a22b",
1661 "Qwen3 235B-A22B",
1662 "0.70",
1663 "8.40",
1664 "0",
1665 "0",
1666 ),
1667 (
1668 "qwen3-coder-plus",
1669 "Qwen3 Coder Plus",
1670 "0.65",
1671 "3.25",
1672 "0",
1673 "0",
1674 ),
1675 (
1676 "qwen3-coder-flash",
1677 "Qwen3 Coder Flash",
1678 "0.195",
1679 "0.975",
1680 "0",
1681 "0",
1682 ),
1683 (
1684 "qwen3-coder-next",
1685 "Qwen3 Coder Next",
1686 "0.12",
1687 "0.75",
1688 "0",
1689 "0",
1690 ),
1691 ("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
1692 ("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
1693 ("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
1694 (
1696 "grok-4.20-0309-reasoning",
1697 "Grok 4.20 Reasoning",
1698 "2",
1699 "6",
1700 "0.20",
1701 "0",
1702 ),
1703 (
1704 "grok-4.20-0309-non-reasoning",
1705 "Grok 4.20",
1706 "2",
1707 "6",
1708 "0.20",
1709 "0",
1710 ),
1711 (
1712 "grok-4-1-fast-reasoning",
1713 "Grok 4.1 Fast Reasoning",
1714 "0.20",
1715 "0.50",
1716 "0.05",
1717 "0",
1718 ),
1719 (
1720 "grok-4-1-fast-non-reasoning",
1721 "Grok 4.1 Fast",
1722 "0.20",
1723 "0.50",
1724 "0.05",
1725 "0",
1726 ),
1727 ("grok-4", "Grok 4", "3", "15", "0.75", "0"),
1728 (
1729 "grok-code-fast-1",
1730 "Grok Code Fast",
1731 "0.20",
1732 "1.50",
1733 "0.02",
1734 "0",
1735 ),
1736 ("grok-3", "Grok 3", "3", "15", "0.75", "0"),
1737 ("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
1738 ("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
1740 (
1741 "devstral-small-1.1",
1742 "Devstral Small 1.1",
1743 "0.07",
1744 "0.28",
1745 "0.01",
1746 "0",
1747 ),
1748 ("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
1749 (
1750 "devstral-medium",
1751 "Devstral Medium",
1752 "0.40",
1753 "2",
1754 "0.04",
1755 "0",
1756 ),
1757 (
1758 "mistral-large-3-2512",
1759 "Mistral Large 3",
1760 "0.50",
1761 "1.50",
1762 "0.05",
1763 "0",
1764 ),
1765 (
1766 "mistral-medium-3.1",
1767 "Mistral Medium 3.1",
1768 "0.40",
1769 "2",
1770 "0.04",
1771 "0",
1772 ),
1773 (
1774 "mistral-small-3.2-24b",
1775 "Mistral Small 3.2",
1776 "0.075",
1777 "0.20",
1778 "0.01",
1779 "0",
1780 ),
1781 ("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
1782 ("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
1784 (
1785 "command-r-plus",
1786 "Cohere Command R+",
1787 "2.50",
1788 "10",
1789 "0",
1790 "0",
1791 ),
1792 ("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
1793 ("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
1795 ("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
1796 ("o1", "OpenAI o1", "15", "60", "7.50", "0"),
1797 ("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
1798 ("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
1799 ("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
1800 ("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
1801 ];
1802
1803 let mut stmt = conn
1804 .prepare(
1805 "INSERT OR IGNORE INTO model_pricing (
1806 model_id, display_name, input_cost_per_million, output_cost_per_million,
1807 cache_read_cost_per_million, cache_creation_cost_per_million
1808 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1809 )
1810 .map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
1811 for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
1812 stmt.execute(rusqlite::params![
1813 model_id,
1814 display_name,
1815 input,
1816 output,
1817 cache_read,
1818 cache_creation
1819 ])
1820 .map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
1821 }
1822
1823 log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
1824 Ok(())
1825 }
1826
1827 pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
1829 let conn = lock_conn!(self.conn);
1830 Self::ensure_model_pricing_seeded_on_conn(&conn)
1831 }
1832
1833 fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
1834 Self::seed_model_pricing(conn)
1837 }
1838
1839 pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
1842 conn.query_row("PRAGMA user_version;", [], |row| row.get(0))
1843 .map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}")))
1844 }
1845
1846 pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
1847 if version < 0 {
1848 return Err(AppError::Database("user_version 不能为负数".to_string()));
1849 }
1850 let sql = format!("PRAGMA user_version = {version};");
1851 conn.execute(&sql, [])
1852 .map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?;
1853 Ok(())
1854 }
1855
1856 fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
1857 if s.is_empty() {
1858 return Err(AppError::Database(format!("{kind} 不能为空")));
1859 }
1860 if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1861 return Err(AppError::Database(format!(
1862 "非法{kind}: {s},仅允许字母、数字和下划线"
1863 )));
1864 }
1865 Ok(())
1866 }
1867
1868 pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
1869 Self::validate_identifier(table, "表名")?;
1870
1871 let mut stmt = conn
1872 .prepare("SELECT name FROM sqlite_master WHERE type='table'")
1873 .map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
1874 let mut rows = stmt
1875 .query([])
1876 .map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
1877 while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
1878 let name: String = row
1879 .get(0)
1880 .map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
1881 if name.eq_ignore_ascii_case(table) {
1882 return Ok(true);
1883 }
1884 }
1885 Ok(false)
1886 }
1887
1888 pub(crate) fn has_column(
1889 conn: &Connection,
1890 table: &str,
1891 column: &str,
1892 ) -> Result<bool, AppError> {
1893 Self::validate_identifier(table, "表名")?;
1894 Self::validate_identifier(column, "列名")?;
1895
1896 let sql = format!("PRAGMA table_info(\"{table}\");");
1897 let mut stmt = conn
1898 .prepare(&sql)
1899 .map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?;
1900 let mut rows = stmt
1901 .query([])
1902 .map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?;
1903 while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
1904 let name: String = row
1905 .get(1)
1906 .map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?;
1907 if name.eq_ignore_ascii_case(column) {
1908 return Ok(true);
1909 }
1910 }
1911 Ok(false)
1912 }
1913
1914 fn add_column_if_missing(
1915 conn: &Connection,
1916 table: &str,
1917 column: &str,
1918 definition: &str,
1919 ) -> Result<bool, AppError> {
1920 Self::validate_identifier(table, "表名")?;
1921 Self::validate_identifier(column, "列名")?;
1922
1923 if !Self::table_exists(conn, table)? {
1924 return Err(AppError::Database(format!(
1925 "表 {table} 不存在,无法添加列 {column}"
1926 )));
1927 }
1928 if Self::has_column(conn, table, column)? {
1929 return Ok(false);
1930 }
1931
1932 let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
1933 conn.execute(&sql, [])
1934 .map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
1935 log::info!("已为表 {table} 添加缺失列 {column}");
1936 Ok(true)
1937 }
1938}