mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
refactor(provider): establish typed write ownership
Replace the generic provider upsert surface with strict typed create, update, rename, endpoint, and compensation operations. This app-independent prerequisite owns provider writes without depending on Pi runtime or canonical restore. The v17 migration also reserves dormant device-local ledger tables and the Pi skill bit alongside endpoint normalization. Keeping that reservation here prevents a later stacked feature from rewriting a published migration; no Pi runtime behavior is activated by this commit. Old save_provider callsite classification ========================================== Inventory authority: abandoned 5a385fc8 tree. The old definition at src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite. Production callsites: - src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates one absent aggregate; it now strict-inserts the row and initial endpoints in one transaction. - src-tauri/src/database/dao/providers.rs:638 [create] official seed first proves absence, then strict-creates; a racing insert is a conflict. - src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first proves absence, then strict-creates; it cannot overwrite an existing row. - src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new aggregate and strict-creates it; OMO is not eligible for rename. - src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used is not a Provider-row save; it now calls the exact touch endpoint operation. - src-tauri/src/services/provider/live.rs:1567 [create/update] default live import is reconciliation: read first, then strict create or strict update. - src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns strict aggregate creation and all initial endpoints. - src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key change now uses the dedicated transactional rename after eligibility checks. - src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly the existing main row after its live-file coordination. - src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit updates exactly the existing main row after resolving live ownership. - src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates exactly the existing main row and never inserts. - src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes only the existing provider's live-managed marker. - src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates only the existing current provider row. - src-tauri/src/services/provider/mod.rs:3174 [update] successful additive switch changes only the existing live-managed marker. - src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed to the dedicated sort-index state operation, not row replacement. - src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to- Claude reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to- Codex reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to- Gemini reconciliation reads the target and selects strict create or update. Required indirect ownership paths: - src-tauri/src/deeplink/provider.rs [create] the old indirect flow called ProviderService::add and then appended endpoints one by one. It now supplies every non-primary endpoint to one strict aggregate create, so hydration is complete atomically and a duplicate is zero-side-effect. - [restore] no old generic-save callsite is reclassified as restore. Exact aggregate replacement exists only as the sealed restore_provider_aggregate_on_tx compensation primitive. Test-only callsites: Every item below is classified [test]. Each is fixture setup, not a production write authority, and is migrated to a real ProviderService entry where the behavior is under test or to the cfg(test)-only typed fixture reconciler where the test merely needs pre-existing rows. - src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup. - src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup. - src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup. - src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup. - src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup. - src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create. - src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create. - src-tauri/tests/provider_commands.rs:69 [test] command fixture create. - src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
This commit is contained in:
Generated
+1
@@ -799,6 +799,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"syn 2.0.117",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -116,3 +116,4 @@ strip = "symbols"
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
tempfile = "3"
|
||||
syn = { version = "2", features = ["full", "visit"] }
|
||||
|
||||
@@ -10,6 +10,10 @@ use crate::codex_state_db::codex_state_db_paths;
|
||||
use crate::config::{atomic_write, copy_file, get_app_config_dir};
|
||||
use crate::database::{is_official_seed_id, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::{
|
||||
provider_row_fingerprint, provider_to_mutation_input,
|
||||
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
||||
};
|
||||
use crate::settings::{
|
||||
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
||||
CodexThirdPartyHistoryProviderBucketMigration,
|
||||
@@ -663,7 +667,8 @@ fn migrate_codex_provider_templates_to_custom(
|
||||
let providers = db.get_all_providers("codex")?;
|
||||
let mut migrated_provider_ids = Vec::new();
|
||||
|
||||
for (_, provider) in providers {
|
||||
for (_, mut provider) in providers {
|
||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||
if provider.category.as_deref() == Some("official")
|
||||
|| is_official_seed_id(&provider.id)
|
||||
|| provider.is_codex_oauth()
|
||||
@@ -694,8 +699,21 @@ fn migrate_codex_provider_templates_to_custom(
|
||||
};
|
||||
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
|
||||
obj.insert("config".to_string(), Value::String(migrated_config_text));
|
||||
db.update_provider_settings_config("codex", &provider.id, &settings)?;
|
||||
migrated_provider_ids.push(provider.id);
|
||||
let provider_id = provider.id.clone();
|
||||
provider.settings_config = settings;
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(provider);
|
||||
reconcile_provider_record_with_precondition(
|
||||
db,
|
||||
"codex",
|
||||
input,
|
||||
ReconcilePrecondition::ExpectPresent {
|
||||
fingerprint: observed_fingerprint,
|
||||
},
|
||||
)?;
|
||||
migrated_provider_ids.push(provider_id);
|
||||
}
|
||||
|
||||
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
||||
@@ -1439,7 +1457,8 @@ base_url = "https://proxy.example/v1"
|
||||
),
|
||||
];
|
||||
for provider in providers {
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
}
|
||||
|
||||
let mut official = Provider::with_id(
|
||||
@@ -1449,7 +1468,8 @@ base_url = "https://proxy.example/v1"
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
db.save_provider("codex", &official).expect("save official");
|
||||
db.reconcile_provider_fixture("codex", &official)
|
||||
.expect("save official");
|
||||
|
||||
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert_eq!(
|
||||
@@ -2171,9 +2191,10 @@ base_url = "https://proxy.example/v1"
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
|
||||
db.save_provider("codex", &third_party)
|
||||
db.reconcile_provider_fixture("codex", &third_party)
|
||||
.expect("save third-party");
|
||||
db.save_provider("codex", &official).expect("save official");
|
||||
db.reconcile_provider_fixture("codex", &official)
|
||||
.expect("save official");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(ids.contains("rightcode"));
|
||||
@@ -2196,7 +2217,8 @@ base_url = "https://proxy.example/v1"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2216,7 +2238,8 @@ base_url = "https://proxy.example/v1"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2244,7 +2267,8 @@ model_provider = "my-private-relay"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2264,7 +2288,8 @@ model_provider = "my-private-relay"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(ids.contains("aihubmix"));
|
||||
@@ -2285,7 +2310,8 @@ model_provider = "my-private-relay"
|
||||
);
|
||||
provider.category = Some("aggregator".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(ids.contains("ccswitch"));
|
||||
@@ -2317,7 +2343,8 @@ model = "gpt-5.4"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
|
||||
@@ -2390,7 +2417,8 @@ base_url = "https://aihubmix.example/v1"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert_eq!(
|
||||
@@ -2446,7 +2474,8 @@ base_url = "http://localhost:8080/v1"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert!(outcome.migrated_provider_ids.is_empty());
|
||||
@@ -2495,7 +2524,8 @@ base_url = "https://proxy.example/v1"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert!(outcome.migrated_provider_ids.is_empty());
|
||||
@@ -2552,7 +2582,8 @@ model_provider = "aihubmix"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
|
||||
@@ -2601,7 +2632,8 @@ model_provider = "aihubmix"
|
||||
provider.category = Some("custom".to_string());
|
||||
provider.created_at = Some(1);
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-private-relay"));
|
||||
@@ -2622,7 +2654,8 @@ model_provider = "aihubmix"
|
||||
);
|
||||
provider.category = Some("custom".to_string());
|
||||
|
||||
db.save_provider("codex", &provider).expect("save provider");
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||
assert!(!ids.contains("my-local-relay"));
|
||||
|
||||
@@ -4,8 +4,9 @@ use tauri::{Emitter, Manager, State};
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::commands::xai_oauth::XaiOAuthState;
|
||||
use crate::database::NewProviderAggregate;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMutationInput};
|
||||
use crate::services::{
|
||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||
};
|
||||
@@ -39,7 +40,7 @@ pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<S
|
||||
pub fn add_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider: Provider,
|
||||
provider: ProviderMutationInput,
|
||||
#[allow(non_snake_case)] addToLive: Option<bool>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
@@ -51,7 +52,7 @@ pub fn add_provider(
|
||||
pub fn update_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider: Provider,
|
||||
provider: ProviderMutationInput,
|
||||
#[allow(non_snake_case)] originalId: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
@@ -250,7 +251,13 @@ pub fn import_claude_desktop_providers_from_claude(
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
|
||||
.create_provider(
|
||||
NewProviderAggregate::from_input(
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
crate::services::provider::provider_to_mutation_input(desktop_provider),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
pub mod provider_write;
|
||||
#[cfg(test)]
|
||||
mod provider_write_certification;
|
||||
pub mod providers;
|
||||
pub mod providers_seed;
|
||||
pub mod proxy;
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ProviderMeta, ProviderMutationInput};
|
||||
use crate::settings::CustomEndpoint;
|
||||
use rusqlite::{params, OptionalExtension, Transaction};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::providers::{StoredProviderRow, PROVIDER_SELECT};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderKey {
|
||||
app_type: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl ProviderKey {
|
||||
pub fn new(app_type: impl Into<String>, id: impl Into<String>) -> Result<Self, AppError> {
|
||||
let app_type = app_type.into();
|
||||
let id = id.into();
|
||||
if app_type.trim().is_empty() || id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider app type and id must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self { app_type, id })
|
||||
}
|
||||
|
||||
pub fn app_type(&self) -> &str {
|
||||
&self.app_type
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowUpdate {
|
||||
name: String,
|
||||
settings_config: Value,
|
||||
website_url: Option<String>,
|
||||
category: Option<String>,
|
||||
notes: Option<String>,
|
||||
meta: ProviderMeta,
|
||||
icon: Option<String>,
|
||||
icon_color: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderRowUpdate {
|
||||
pub fn from_input(input: &ProviderMutationInput) -> Result<Self, AppError> {
|
||||
let meta = input.meta.clone().unwrap_or_default();
|
||||
if !meta.custom_endpoints.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider update must not contain customEndpoints; use endpoint operations"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
name: input.name.clone(),
|
||||
settings_config: input.settings_config.clone(),
|
||||
website_url: input.website_url.clone(),
|
||||
category: input.category.clone(),
|
||||
notes: input.notes.clone(),
|
||||
meta,
|
||||
icon: input.icon.clone(),
|
||||
icon_color: input.icon_color.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowCreate {
|
||||
content: ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewEndpoint {
|
||||
url: String,
|
||||
added_at: Option<i64>,
|
||||
last_used: Option<i64>,
|
||||
}
|
||||
|
||||
impl NewEndpoint {
|
||||
pub fn new(
|
||||
url: impl Into<String>,
|
||||
added_at: Option<i64>,
|
||||
last_used: Option<i64>,
|
||||
) -> Result<Self, AppError> {
|
||||
let url = url.into();
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider endpoint URL cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
url,
|
||||
added_at,
|
||||
last_used,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn now(url: impl Into<String>) -> Result<Self, AppError> {
|
||||
Self::new(url, Some(chrono::Utc::now().timestamp_millis()), None)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<CustomEndpoint> for NewEndpoint {
|
||||
type Error = AppError;
|
||||
|
||||
fn try_from(endpoint: CustomEndpoint) -> Result<Self, Self::Error> {
|
||||
Self::new(endpoint.url, endpoint.added_at, endpoint.last_used)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProviderAggregate {
|
||||
key: ProviderKey,
|
||||
row: ProviderRowCreate,
|
||||
sort_index: Option<usize>,
|
||||
in_failover_queue: bool,
|
||||
initial_endpoints: Vec<NewEndpoint>,
|
||||
}
|
||||
|
||||
impl NewProviderAggregate {
|
||||
pub fn from_input(app_type: &str, mut input: ProviderMutationInput) -> Result<Self, AppError> {
|
||||
let endpoints = input
|
||||
.meta
|
||||
.as_mut()
|
||||
.map(|meta| std::mem::take(&mut meta.custom_endpoints))
|
||||
.unwrap_or_default();
|
||||
let mut seen = HashSet::with_capacity(endpoints.len());
|
||||
let mut initial_endpoints = Vec::with_capacity(endpoints.len());
|
||||
for (key, endpoint) in endpoints {
|
||||
let normalized_key = key.trim().trim_end_matches('/').to_string();
|
||||
let normalized_url = endpoint.url.trim().trim_end_matches('/').to_string();
|
||||
if normalized_key != normalized_url {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"provider endpoint key '{key}' must match endpoint URL '{}'",
|
||||
endpoint.url
|
||||
)));
|
||||
}
|
||||
if !seen.insert(normalized_url.clone()) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"duplicate initial provider endpoint '{}'",
|
||||
endpoint.url
|
||||
)));
|
||||
}
|
||||
initial_endpoints.push(NewEndpoint::new(
|
||||
normalized_url,
|
||||
endpoint.added_at,
|
||||
endpoint.last_used,
|
||||
)?);
|
||||
}
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
let row = ProviderRowCreate {
|
||||
content: ProviderRowUpdate::from_input(&input)?,
|
||||
created_at: input.created_at,
|
||||
};
|
||||
Ok(Self {
|
||||
key,
|
||||
row,
|
||||
sort_index: input.sort_index,
|
||||
in_failover_queue: input.in_failover_queue,
|
||||
initial_endpoints,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenameProvider {
|
||||
source: ProviderKey,
|
||||
target_id: String,
|
||||
row: ProviderRowUpdate,
|
||||
}
|
||||
|
||||
impl RenameProvider {
|
||||
pub fn from_input(
|
||||
source: ProviderKey,
|
||||
input: &ProviderMutationInput,
|
||||
) -> Result<Self, AppError> {
|
||||
if !matches!(source.app_type(), "opencode" | "openclaw") {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider key changes are restricted to additive OpenCode/OpenClaw providers"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if source.id() == input.id {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider rename requires a different target id".to_string(),
|
||||
));
|
||||
}
|
||||
if input.id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"provider target id must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
let mut row = ProviderRowUpdate::from_input(input)?;
|
||||
// A successful key change always remains DB-only. The service owns
|
||||
// the corresponding live-file absence check, while the DAO persists
|
||||
// the durable half of that invariant.
|
||||
row.meta.live_config_managed = Some(false);
|
||||
Ok(Self {
|
||||
source,
|
||||
target_id: input.id.clone(),
|
||||
row,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
|
||||
let settings_config = serde_json::to_string(&row.settings_config).map_err(|error| {
|
||||
AppError::Database(format!("failed to serialize settings_config: {error}"))
|
||||
})?;
|
||||
let meta = serde_json::to_string(&row.meta).map_err(|error| {
|
||||
AppError::Database(format!("failed to serialize provider meta: {error}"))
|
||||
})?;
|
||||
Ok((settings_config, meta))
|
||||
}
|
||||
|
||||
fn insert_row(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
sort_index: Option<usize>,
|
||||
is_current: bool,
|
||||
in_failover_queue: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let (settings_config, meta) = encode_row(row)?;
|
||||
tx.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta,
|
||||
is_current, in_failover_queue
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
|
||||
)",
|
||||
params![
|
||||
key.id,
|
||||
key.app_type,
|
||||
row.name,
|
||||
settings_config,
|
||||
row.website_url,
|
||||
row.category,
|
||||
created_at,
|
||||
sort_index,
|
||||
row.notes,
|
||||
row.icon,
|
||||
row.icon_color,
|
||||
meta,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
)
|
||||
.map_err(|error| match &error {
|
||||
rusqlite::Error::SqliteFailure(code, _)
|
||||
if matches!(
|
||||
code.extended_code,
|
||||
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
) =>
|
||||
{
|
||||
AppError::Conflict(format!(
|
||||
"provider '{}/{}' already exists",
|
||||
key.app_type, key.id
|
||||
))
|
||||
}
|
||||
_ => AppError::Database(error.to_string()),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_endpoint(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
endpoint: &NewEndpoint,
|
||||
) -> Result<(), AppError> {
|
||||
tx.execute(
|
||||
"INSERT INTO provider_endpoints
|
||||
(provider_id, app_type, url, added_at, last_used)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
key.id,
|
||||
key.app_type,
|
||||
endpoint.url,
|
||||
endpoint.added_at,
|
||||
endpoint.last_used
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exact aggregate replacement is sealed inside the DAO parent module. The
|
||||
/// catalog compensation coordinator introduced with the ordered mutation
|
||||
/// pipeline is the only intended caller.
|
||||
#[allow(dead_code)]
|
||||
// The certification contract keeps immutable creation time separate from the
|
||||
// mutable row DTO and calls this sealed helper directly with the full snapshot.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn restore_provider_aggregate_on_tx(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
sort_index: Option<usize>,
|
||||
is_current: bool,
|
||||
in_failover_queue: bool,
|
||||
endpoints: &[NewEndpoint],
|
||||
) -> Result<(), AppError> {
|
||||
let updated = update_row(tx, key, row)?;
|
||||
if updated == 0 {
|
||||
insert_row(
|
||||
tx,
|
||||
key,
|
||||
row,
|
||||
created_at,
|
||||
sort_index,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
)?;
|
||||
} else {
|
||||
// Exact compensation is the only path allowed to restore immutable
|
||||
// creation time after a prior aggregate mutation.
|
||||
tx.execute(
|
||||
"UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![created_at, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
|
||||
params![key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
for endpoint in endpoints {
|
||||
insert_endpoint(tx, key, endpoint)?;
|
||||
}
|
||||
// State and order are maintained by their dedicated authorities. Exact
|
||||
// compensation may restore their captured values without exposing them in
|
||||
// ProviderRowUpdate.
|
||||
tx.execute(
|
||||
"UPDATE providers
|
||||
SET sort_index = ?1, is_current = ?2, in_failover_queue = ?3
|
||||
WHERE id = ?4 AND app_type = ?5",
|
||||
params![
|
||||
sort_index,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
key.id,
|
||||
key.app_type
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_row(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<usize, AppError> {
|
||||
let (settings_config, meta) = encode_row(row)?;
|
||||
tx.execute(
|
||||
"UPDATE providers SET
|
||||
name = ?1,
|
||||
settings_config = ?2,
|
||||
website_url = ?3,
|
||||
category = ?4,
|
||||
notes = ?5,
|
||||
icon = ?6,
|
||||
icon_color = ?7,
|
||||
meta = ?8
|
||||
WHERE id = ?9 AND app_type = ?10",
|
||||
params![
|
||||
row.name,
|
||||
settings_config,
|
||||
row.website_url,
|
||||
row.category,
|
||||
row.notes,
|
||||
row.icon,
|
||||
row.icon_color,
|
||||
meta,
|
||||
key.id,
|
||||
key.app_type,
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn create_provider(&self, input: NewProviderAggregate) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
insert_row(
|
||||
&tx,
|
||||
&input.key,
|
||||
&input.row.content,
|
||||
input.row.created_at,
|
||||
input.sort_index,
|
||||
false,
|
||||
input.in_failover_queue,
|
||||
)?;
|
||||
for endpoint in &input.initial_endpoints {
|
||||
insert_endpoint(&tx, &input.key, endpoint)?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn update_provider(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if update_row(&tx, key, row)? != 1 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn update_provider_if_content_fingerprint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
expected_fingerprint: &str,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let current = tx
|
||||
.query_row(
|
||||
&format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"),
|
||||
params![key.id, key.app_type],
|
||||
StoredProviderRow::from_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))?
|
||||
.decode(key.app_type())?;
|
||||
if current.row_content_fingerprint() != expected_fingerprint {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"provider '{}/{}' changed since it was read",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
if update_row(&tx, key, row)? != 1 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn rename_db_only_additive_provider(
|
||||
&self,
|
||||
input: RenameProvider,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let source_state = tx
|
||||
.query_row(
|
||||
"SELECT sort_index, is_current, in_failover_queue, category, created_at, meta
|
||||
FROM providers
|
||||
WHERE id = ?1 AND app_type = ?2",
|
||||
params![input.source.id, input.source.app_type],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, Option<usize>>(0)?,
|
||||
row.get::<_, bool>(1)?,
|
||||
row.get::<_, bool>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<i64>>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
input.source.app_type, input.source.id
|
||||
))
|
||||
})?;
|
||||
if matches!(source_state.3.as_deref(), Some("omo" | "omo-slim")) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"OMO/OMO Slim providers cannot be renamed".to_string(),
|
||||
));
|
||||
}
|
||||
let source_meta: ProviderMeta = if source_state.5.trim().is_empty() {
|
||||
ProviderMeta::default()
|
||||
} else {
|
||||
serde_json::from_str(&source_state.5).map_err(|error| {
|
||||
AppError::Database(format!(
|
||||
"invalid meta for provider '{}/{}': {error}",
|
||||
input.source.app_type, input.source.id
|
||||
))
|
||||
})?
|
||||
};
|
||||
if source_meta.live_config_managed == Some(true) {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"provider '{}/{}' became live-managed before rename",
|
||||
input.source.app_type, input.source.id
|
||||
)));
|
||||
}
|
||||
let target = ProviderKey::new(&input.source.app_type, &input.target_id)?;
|
||||
insert_row(
|
||||
&tx,
|
||||
&target,
|
||||
&input.row,
|
||||
source_state.4,
|
||||
source_state.0,
|
||||
source_state.1,
|
||||
source_state.2,
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO provider_endpoints
|
||||
(provider_id, app_type, url, added_at, last_used)
|
||||
SELECT ?1, app_type, url, added_at, last_used
|
||||
FROM provider_endpoints
|
||||
WHERE provider_id = ?2 AND app_type = ?3
|
||||
ORDER BY id",
|
||||
params![target.id, input.source.id, input.source.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if tx
|
||||
.execute(
|
||||
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![input.source.id, input.source.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
input.source.app_type, input.source.id
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn add_provider_endpoint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
endpoint: NewEndpoint,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
insert_endpoint(&tx, key, &endpoint)?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn remove_provider_endpoint(&self, key: &ProviderKey, url: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if conn
|
||||
.execute(
|
||||
"DELETE FROM provider_endpoints
|
||||
WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
||||
params![key.id, key.app_type, url],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider endpoint '{}/{}/{}'",
|
||||
key.app_type, key.id, url
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn touch_provider_endpoint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
url: &str,
|
||||
at: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if conn
|
||||
.execute(
|
||||
"UPDATE provider_endpoints
|
||||
SET last_used = ?1
|
||||
WHERE provider_id = ?2 AND app_type = ?3 AND url = ?4",
|
||||
params![at, key.id, key.app_type, url],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider endpoint '{}/{}/{}'",
|
||||
key.app_type, key.id, url
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn update_provider_sort_index(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
sort_index: usize,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if conn
|
||||
.execute(
|
||||
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![sort_index, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,9 @@ mod schema;
|
||||
mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub use dao::provider_write::{
|
||||
NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
|
||||
};
|
||||
pub(crate) use dao::providers_seed::{
|
||||
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
|
||||
GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
@@ -53,7 +56,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 16;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 17;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -197,6 +200,11 @@ impl Database {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.create_tables()?;
|
||||
// Keep the test database structurally identical to a fresh production
|
||||
// database. Marking the base DDL as current without running the
|
||||
// migration chain creates a false-current schema and makes restore
|
||||
// tests certify columns that do not actually exist.
|
||||
db.apply_schema_migrations()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
Ok(db)
|
||||
@@ -293,3 +301,39 @@ impl Database {
|
||||
Ok(count == 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Database {
|
||||
/// Test-fixture reconciliation helper. Production code cannot call this:
|
||||
/// provider writes there must choose a typed create or update operation.
|
||||
pub(crate) fn reconcile_provider_fixture(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &crate::provider::Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let mut input = crate::provider::ProviderMutationInput {
|
||||
id: provider.id.clone(),
|
||||
name: provider.name.clone(),
|
||||
settings_config: provider.settings_config.clone(),
|
||||
website_url: provider.website_url.clone(),
|
||||
category: provider.category.clone(),
|
||||
created_at: provider.created_at,
|
||||
sort_index: provider.sort_index,
|
||||
notes: provider.notes.clone(),
|
||||
meta: provider.meta.clone(),
|
||||
icon: provider.icon.clone(),
|
||||
icon_color: provider.icon_color.clone(),
|
||||
in_failover_queue: provider.in_failover_queue,
|
||||
};
|
||||
if self.get_provider_aggregate(app_type, &input.id)?.is_some() {
|
||||
if let Some(meta) = input.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
self.update_provider(&key, &row)
|
||||
} else {
|
||||
self.create_provider(NewProviderAggregate::from_input(app_type, input)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ impl Database {
|
||||
app_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
added_at INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
last_used INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type)
|
||||
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
||||
UNIQUE (provider_id, app_type, url)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -97,6 +100,7 @@ impl Database {
|
||||
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
|
||||
@@ -105,6 +109,36 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// Reserve the v17 device-local ledgers here so later stacked features
|
||||
// never mutate the semantics of an already-published migration.
|
||||
conn.execute(
|
||||
"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
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"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(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 6. Skill Repos 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
||||
@@ -511,6 +545,13 @@ impl Database {
|
||||
Self::migrate_v15_to_v16(conn)?;
|
||||
Self::set_user_version(conn, 16)?;
|
||||
}
|
||||
16 => {
|
||||
log::info!(
|
||||
"迁移数据库从 v16 到 v17(规范化 provider endpoint 并预留设备本地 ledger)"
|
||||
);
|
||||
Self::migrate_v16_to_v17(conn)?;
|
||||
Self::set_user_version(conn, 17)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1523,6 +1564,112 @@ impl Database {
|
||||
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
|
||||
}
|
||||
|
||||
/// v16 -> v17: make endpoint rows a lossless, uniquely owned child
|
||||
/// collection. The device-local ledger DDL is reserved in the same
|
||||
/// migration because later stacked PRs must not rewrite a released
|
||||
/// user_version step.
|
||||
fn migrate_v16_to_v17(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "provider_endpoints")? {
|
||||
Self::add_column_if_missing(conn, "provider_endpoints", "last_used", "INTEGER")?;
|
||||
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
|
||||
);
|
||||
DROP TABLE IF EXISTS provider_endpoints_v17_canonical;
|
||||
CREATE TABLE provider_endpoints_v17_canonical (
|
||||
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)
|
||||
);
|
||||
INSERT INTO provider_endpoints_v17_canonical
|
||||
(id, provider_id, app_type, url, added_at, last_used)
|
||||
SELECT id, provider_id, app_type, url, added_at, last_used
|
||||
FROM provider_endpoints;
|
||||
DROP TABLE provider_endpoints;
|
||||
ALTER TABLE provider_endpoints_v17_canonical
|
||||
RENAME TO provider_endpoints;",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"CREATE TABLE 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)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
if Self::table_exists(conn, "skills")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_pi",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
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(error.to_string()))
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -3222,7 +3369,7 @@ mod tests {
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, 16);
|
||||
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'),
|
||||
@@ -3235,4 +3382,67 @@ mod tests {
|
||||
assert_eq!(counts, (0, 1, 0, 1));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v16_to_v17_preserves_endpoint_metadata_and_starts_ledgers_empty(
|
||||
) -> 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)?;
|
||||
|
||||
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 endpoint: (i64, Option<i64>) = 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)));
|
||||
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!(ledgers, (0, 0));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! 数据库模块测试
|
||||
//!
|
||||
//! 包含 Schema 迁移和基本功能的测试。
|
||||
|
||||
@@ -109,27 +109,35 @@ pub fn import_provider_from_deeplink(
|
||||
|
||||
let provider_id = provider.id.clone();
|
||||
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider, true)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||
// All endpoints supplied by one import request belong to the same create
|
||||
// intent. Put the non-primary endpoints into the initial aggregate so the
|
||||
// provider row and its complete endpoint set commit atomically.
|
||||
let initial_endpoints = &mut provider
|
||||
.meta
|
||||
.get_or_insert_with(ProviderMeta::default)
|
||||
.custom_endpoints;
|
||||
for endpoint in all_endpoints.iter().skip(1) {
|
||||
let normalized = endpoint.trim().trim_end_matches('/').to_string();
|
||||
if !normalized.is_empty() {
|
||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||
state,
|
||||
app_type.clone(),
|
||||
&provider_id,
|
||||
initial_endpoints.insert(
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to add custom endpoint '{}': {e}",
|
||||
crate::url_for_log(&normalized)
|
||||
);
|
||||
}
|
||||
crate::settings::CustomEndpoint {
|
||||
url: normalized,
|
||||
added_at: Some(timestamp),
|
||||
last_used: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderService owns the strict aggregate create.
|
||||
ProviderService::add(
|
||||
state,
|
||||
app_type.clone(),
|
||||
crate::services::provider::provider_to_mutation_input(provider),
|
||||
true,
|
||||
)?;
|
||||
|
||||
// If enabled=true, set as current provider
|
||||
if merged_request.enabled.unwrap_or(false) {
|
||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Deep link module tests
|
||||
|
||||
use super::mcp::parse_mcp_apps;
|
||||
use super::parser::parse_deeplink_url;
|
||||
use super::prompt::import_prompt_from_deeplink;
|
||||
use super::provider::parse_and_merge_config;
|
||||
use super::provider::{import_provider_from_deeplink, parse_and_merge_config};
|
||||
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
||||
use super::DeepLinkImportRequest;
|
||||
use crate::AppType;
|
||||
@@ -952,6 +954,39 @@ fn test_parse_multiple_endpoints_comma_separated() {
|
||||
assert!(endpoint.contains("https://api3.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn provider_deeplink_creates_all_initial_endpoints_in_one_aggregate() {
|
||||
let _test_home = TestHomeGuard::new();
|
||||
let request = parse_deeplink_url(
|
||||
"ccswitch://v1/import?resource=provider&app=claude&name=Endpoint%20Aggregate&endpoint=https%3A%2F%2Fprimary.example.com,https%3A%2F%2Fsecond.example.com%2F,https%3A%2F%2Fthird.example.com&apiKey=sk-test",
|
||||
)
|
||||
.expect("parse provider deeplink");
|
||||
let state = AppState::new(Arc::new(Database::memory().expect("create memory db")));
|
||||
|
||||
let provider_id =
|
||||
import_provider_from_deeplink(&state, request).expect("import provider aggregate");
|
||||
let aggregate = state
|
||||
.db
|
||||
.get_provider_aggregate(AppType::Claude.as_str(), &provider_id)
|
||||
.expect("read provider aggregate")
|
||||
.expect("provider exists");
|
||||
|
||||
assert_eq!(aggregate.endpoints.len(), 2);
|
||||
assert_eq!(
|
||||
aggregate.endpoints["https://second.example.com"].url,
|
||||
"https://second.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate.endpoints["https://third.example.com"].url,
|
||||
"https://third.example.com"
|
||||
);
|
||||
assert!(aggregate
|
||||
.endpoints
|
||||
.values()
|
||||
.all(|endpoint| endpoint.added_at.is_some() && endpoint.last_used.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_endpoint_backward_compatible() {
|
||||
// Old format with single endpoint should still work
|
||||
|
||||
@@ -9,6 +9,13 @@ pub enum AppError {
|
||||
Config(String),
|
||||
#[error("无效输入: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("未找到: {0}")]
|
||||
NotFound(String),
|
||||
/// 结构化冲突:并发前置期望失败(如 reconcile 的 ExpectAbsent 撞上竞争
|
||||
/// 创建、ExpectPresent 的指纹过期)。调用方据此重读重试或上浮,不得解析
|
||||
/// Database(String) 文本。由前置工程 A 认证契约引入(T9)。
|
||||
#[error("并发冲突: {0}")]
|
||||
Conflict(String),
|
||||
#[error("IO 错误: {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
|
||||
+11
-4
@@ -45,7 +45,10 @@ pub use codex_config::{
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::{Database, Profile};
|
||||
pub use database::{
|
||||
Database, NewEndpoint, NewProviderAggregate, Profile, ProviderKey, ProviderRowUpdate,
|
||||
RenameProvider,
|
||||
};
|
||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||
pub use error::AppError;
|
||||
pub use grok_config::get_grok_config_path;
|
||||
@@ -57,7 +60,7 @@ pub use mcp::{
|
||||
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use prompt::Prompt;
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use provider::{Provider, ProviderAggregate, ProviderMeta, ProviderMutationInput};
|
||||
pub use services::{
|
||||
profile::{ProfilePayload, ProfileScope, ProfileService},
|
||||
provider::reapply_current_codex_official_live,
|
||||
@@ -1988,6 +1991,7 @@ fn initialize_common_config_snippets(state: &store::AppState) {
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_run_legacy_migration {
|
||||
let mut legacy_migration_succeeded = true;
|
||||
for app_type in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
@@ -2001,11 +2005,14 @@ fn initialize_common_config_snippets(state: &store::AppState) {
|
||||
"✗ Failed to migrate legacy common-config usage for {}: {e}",
|
||||
app_type.as_str()
|
||||
);
|
||||
legacy_migration_succeeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||
if legacy_migration_succeeded {
|
||||
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,84 @@ pub struct Provider {
|
||||
pub in_failover_queue: bool,
|
||||
}
|
||||
|
||||
/// IPC/service input for creating or editing a provider.
|
||||
///
|
||||
/// This deliberately is not the hydrated [`Provider`] read projection. In
|
||||
/// particular, callers cannot pass a DAO aggregate back into the provider-row
|
||||
/// writer without first crossing the service boundary, where endpoint
|
||||
/// ownership is checked.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderMutationInput {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "settingsConfig")]
|
||||
pub settings_config: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "websiteUrl")]
|
||||
pub website_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "sortIndex")]
|
||||
pub sort_index: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<ProviderMeta>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "inFailoverQueue")]
|
||||
pub in_failover_queue: bool,
|
||||
}
|
||||
|
||||
impl From<ProviderMutationInput> for Provider {
|
||||
fn from(input: ProviderMutationInput) -> Self {
|
||||
Self {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
settings_config: input.settings_config,
|
||||
website_url: input.website_url,
|
||||
category: input.category,
|
||||
created_at: input.created_at,
|
||||
sort_index: input.sort_index,
|
||||
notes: input.notes,
|
||||
meta: input.meta,
|
||||
icon: input.icon,
|
||||
icon_color: input.icon_color,
|
||||
in_failover_queue: input.in_failover_queue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider row and every endpoint owned by that row.
|
||||
///
|
||||
/// SQLite stores endpoints separately from provider metadata. This aggregate
|
||||
/// is the only lossless DAO boundary; legacy `Provider` reads are projections
|
||||
/// of it for API compatibility.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderAggregate {
|
||||
pub provider: Provider,
|
||||
#[serde(default)]
|
||||
pub endpoints: IndexMap<String, crate::settings::CustomEndpoint>,
|
||||
}
|
||||
|
||||
impl ProviderAggregate {
|
||||
pub(crate) fn into_provider(mut self) -> Provider {
|
||||
self.provider
|
||||
.meta
|
||||
.get_or_insert_with(ProviderMeta::default)
|
||||
.custom_endpoints = self.endpoints.into_iter().collect();
|
||||
self.provider
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// 从现有ID创建供应商
|
||||
pub fn with_id(
|
||||
@@ -67,6 +145,72 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_content_fingerprint(&self) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn hash_canonical(value: &serde_json::Value, hasher: &mut Sha256) {
|
||||
match value {
|
||||
serde_json::Value::Null => hasher.update(b"n"),
|
||||
serde_json::Value::Bool(value) => {
|
||||
hasher.update(b"b");
|
||||
hasher.update([*value as u8]);
|
||||
}
|
||||
serde_json::Value::Number(value) => {
|
||||
let text = value.to_string();
|
||||
hasher.update(b"#");
|
||||
hasher.update((text.len() as u64).to_le_bytes());
|
||||
hasher.update(text.as_bytes());
|
||||
}
|
||||
serde_json::Value::String(value) => {
|
||||
hasher.update(b"s");
|
||||
hasher.update((value.len() as u64).to_le_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
hasher.update(b"[");
|
||||
hasher.update((items.len() as u64).to_le_bytes());
|
||||
for item in items {
|
||||
hash_canonical(item, hasher);
|
||||
}
|
||||
hasher.update(b"]");
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
hasher.update(b"{");
|
||||
hasher.update((map.len() as u64).to_le_bytes());
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
hasher.update((key.len() as u64).to_le_bytes());
|
||||
hasher.update(key.as_bytes());
|
||||
hash_canonical(&map[key.as_str()], hasher);
|
||||
}
|
||||
hasher.update(b"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut meta = serde_json::to_value(&self.meta).unwrap_or(serde_json::Value::Null);
|
||||
if let serde_json::Value::Object(map) = &mut meta {
|
||||
map.remove("custom_endpoints");
|
||||
map.remove("customEndpoints");
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
for part in [
|
||||
serde_json::Value::String(self.name.clone()),
|
||||
self.settings_config.clone(),
|
||||
serde_json::to_value(&self.website_url).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.category).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.notes).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.icon).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::to_value(&self.icon_color).unwrap_or(serde_json::Value::Null),
|
||||
meta,
|
||||
] {
|
||||
hash_canonical(&part, &mut hasher);
|
||||
hasher.update([0u8]);
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn is_codex_oauth(&self) -> bool {
|
||||
self.provider_type() == Some("codex_oauth")
|
||||
}
|
||||
|
||||
@@ -348,8 +348,10 @@ mod tests {
|
||||
let provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
|
||||
@@ -374,8 +376,10 @@ mod tests {
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
provider_b.sort_index = Some(1);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
@@ -407,8 +411,10 @@ mod tests {
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
provider_b.sort_index = Some(1);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
|
||||
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
||||
@@ -444,8 +450,10 @@ mod tests {
|
||||
let provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
@@ -485,7 +493,8 @@ mod tests {
|
||||
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
|
||||
// 启用自动故障转移
|
||||
|
||||
@@ -4,15 +4,32 @@
|
||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
||||
|
||||
type PerAppLocks = Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>;
|
||||
|
||||
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
||||
///
|
||||
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Clone)]
|
||||
pub struct SwitchLockManager {
|
||||
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
locks: PerAppLocks,
|
||||
}
|
||||
|
||||
impl Default for SwitchLockManager {
|
||||
fn default() -> Self {
|
||||
// Some commands construct a short-lived AppState around the shared
|
||||
// database before running a blocking sync. A per-ProxyService map
|
||||
// would give those paths a different lock and defeat serialization
|
||||
// with provider rename/switch operations in the primary AppState.
|
||||
static LOCKS: OnceLock<PerAppLocks> = OnceLock::new();
|
||||
Self {
|
||||
locks: LOCKS
|
||||
.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SwitchLockManager {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::config::{atomic_write, write_json_file};
|
||||
use crate::database::NewProviderAggregate;
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
use crate::provider::Provider;
|
||||
@@ -288,7 +289,10 @@ impl OmoService {
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
state.db.save_provider("opencode", &provider)?;
|
||||
state.db.create_provider(NewProviderAggregate::from_input(
|
||||
"opencode",
|
||||
crate::services::provider::provider_to_mutation_input(provider.clone()),
|
||||
)?)?;
|
||||
state
|
||||
.db
|
||||
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::{NewEndpoint, ProviderKey};
|
||||
use crate::error::AppError;
|
||||
use crate::settings::CustomEndpoint;
|
||||
use crate::store::AppState;
|
||||
@@ -47,9 +48,10 @@ pub fn add_custom_endpoint(
|
||||
));
|
||||
}
|
||||
|
||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||
state
|
||||
.db
|
||||
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||
.add_provider_endpoint(&key, NewEndpoint::now(normalized)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -61,9 +63,8 @@ pub fn remove_custom_endpoint(
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
state
|
||||
.db
|
||||
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||
state.db.remove_provider_endpoint(&key, &normalized)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -76,17 +77,10 @@ pub fn update_endpoint_last_used(
|
||||
) -> Result<(), AppError> {
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
// Get provider, update last_used, save back
|
||||
let mut providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get_mut(provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
|
||||
endpoint.last_used = Some(now_millis());
|
||||
state.db.save_provider(app_type.as_str(), provider)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||
state
|
||||
.db
|
||||
.touch_provider_endpoint(&key, &normalized, now_millis())
|
||||
}
|
||||
|
||||
/// Get current timestamp in milliseconds
|
||||
|
||||
@@ -19,7 +19,10 @@ use crate::store::AppState;
|
||||
use super::gemini_auth::{
|
||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
|
||||
};
|
||||
use super::normalize_claude_models_in_value;
|
||||
use super::{
|
||||
normalize_claude_models_in_value, provider_row_fingerprint, provider_to_mutation_input,
|
||||
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
||||
};
|
||||
|
||||
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
|
||||
/// effective budget (openai/codex#31860), far below the 1.05M API spec.
|
||||
@@ -1279,6 +1282,12 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
// Sync providers based on mode
|
||||
for app_type in AppType::all() {
|
||||
if app_type.is_additive_mode() {
|
||||
// Provider rename and every additive live mutation share this
|
||||
// per-app lock. Acquire it before reading the catalog so a key
|
||||
// cannot be renamed after this sync captured a stale provider map.
|
||||
let _guard = futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
);
|
||||
// Additive mode: sync ALL providers
|
||||
sync_all_providers_to_live(state, &app_type)?;
|
||||
} else {
|
||||
@@ -1564,7 +1573,12 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
app_type.as_str(),
|
||||
provider_to_mutation_input(provider.clone()),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
)?;
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
@@ -1732,15 +1746,25 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
};
|
||||
|
||||
if existing_ids.contains(&id) {
|
||||
match state.db.get_provider_by_id(&id, "opencode") {
|
||||
match state.db.get_provider_aggregate("opencode", &id) {
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
|
||||
if existing.settings_config != settings_config || existing.name != display_name
|
||||
{
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.name = display_name;
|
||||
provider.settings_config = settings_config;
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"opencode",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update OpenCode provider '{id}' from live config: {e}"
|
||||
);
|
||||
@@ -1767,7 +1791,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"opencode",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
@@ -1817,12 +1846,22 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
};
|
||||
|
||||
if existing_ids.contains(&id) {
|
||||
match state.db.get_provider_by_id(&id, "openclaw") {
|
||||
match state.db.get_provider_aggregate("openclaw", &id) {
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
if existing.settings_config != settings_config {
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.settings_config = settings_config;
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"openclaw",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update OpenClaw provider '{id}' from live config: {e}"
|
||||
);
|
||||
@@ -1855,7 +1894,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"openclaw",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
@@ -1892,12 +1936,22 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
||||
}
|
||||
|
||||
if existing_ids.contains(&name) {
|
||||
match state.db.get_provider_by_id(&name, "hermes") {
|
||||
match state.db.get_provider_aggregate("hermes", &name) {
|
||||
Ok(Some(existing)) => {
|
||||
let existing = existing.provider;
|
||||
if existing.settings_config != config {
|
||||
let fingerprint = provider_row_fingerprint(&existing);
|
||||
let mut provider = existing;
|
||||
provider.settings_config = config;
|
||||
if let Err(e) = state.db.save_provider("hermes", &provider) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"hermes",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to update Hermes provider '{name}' from live config: {e}"
|
||||
);
|
||||
@@ -1923,7 +1977,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("hermes", &provider) {
|
||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||
&state.db,
|
||||
"hermes",
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectAbsent,
|
||||
) {
|
||||
log::warn!("Failed to import Hermes provider '{name}': {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
+1076
-139
File diff suppressed because it is too large
Load Diff
+294
-221
@@ -10,7 +10,9 @@ use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::switch_lock::SwitchLockManager;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
build_effective_settings_with_common_config, provider_row_fingerprint,
|
||||
provider_to_mutation_input, reconcile_provider_record_with_precondition,
|
||||
write_live_with_common_config, ReconcilePrecondition,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::str::FromStr;
|
||||
@@ -980,6 +982,27 @@ impl ProxyService {
|
||||
.await
|
||||
}
|
||||
|
||||
fn persist_synced_live_token(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
observed_fingerprint: String,
|
||||
mut provider: Provider,
|
||||
) -> Result<(), String> {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
reconcile_provider_record_with_precondition(
|
||||
self.db.as_ref(),
|
||||
app_type,
|
||||
provider_to_mutation_input(provider),
|
||||
ReconcilePrecondition::ExpectPresent {
|
||||
fingerprint: observed_fingerprint,
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("同步 {app_type}/{provider_id} Live Token 到数据库失败: {error}"))
|
||||
}
|
||||
|
||||
async fn sync_live_config_to_provider(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
@@ -992,91 +1015,89 @@ impl ProxyService {
|
||||
.map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?;
|
||||
|
||||
if let Some(provider_id) = provider_id {
|
||||
if let Ok(Some(mut provider)) =
|
||||
self.db.get_provider_by_id(&provider_id, "claude")
|
||||
{
|
||||
if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) {
|
||||
let token_pair = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
env.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| (key, s.trim()))
|
||||
})
|
||||
.filter(|(_, token)| {
|
||||
!token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
let Some(mut provider) = self
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, "claude")
|
||||
.map_err(|error| {
|
||||
format!("读取 Claude 供应商 '{provider_id}' 失败: {error}")
|
||||
})?
|
||||
else {
|
||||
return Err(format!("Claude 当前供应商不存在: {provider_id}"));
|
||||
};
|
||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||
if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) {
|
||||
let token_pair = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
env.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| (key, s.trim()))
|
||||
})
|
||||
.filter(|(_, token)| {
|
||||
!token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
|
||||
if let Some((token_key, token)) = token_pair {
|
||||
let env_obj = provider
|
||||
.settings_config
|
||||
.get_mut("env")
|
||||
.and_then(|v| v.as_object_mut());
|
||||
if let Some((token_key, token)) = token_pair {
|
||||
let env_obj = provider
|
||||
.settings_config
|
||||
.get_mut("env")
|
||||
.and_then(|v| v.as_object_mut());
|
||||
|
||||
match env_obj {
|
||||
Some(obj) => {
|
||||
if token_key == "ANTHROPIC_AUTH_TOKEN"
|
||||
|| token_key == "ANTHROPIC_API_KEY"
|
||||
{
|
||||
let mut updated = false;
|
||||
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
|
||||
obj.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(token),
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
if obj.contains_key("ANTHROPIC_API_KEY") {
|
||||
obj.insert(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
json!(token),
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
if !updated {
|
||||
obj.insert(token_key.to_string(), json!(token));
|
||||
}
|
||||
} else {
|
||||
match env_obj {
|
||||
Some(obj) => {
|
||||
if token_key == "ANTHROPIC_AUTH_TOKEN"
|
||||
|| token_key == "ANTHROPIC_API_KEY"
|
||||
{
|
||||
let mut updated = false;
|
||||
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
|
||||
obj.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(token),
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
if obj.contains_key("ANTHROPIC_API_KEY") {
|
||||
obj.insert(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
json!(token),
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
if !updated {
|
||||
obj.insert(token_key.to_string(), json!(token));
|
||||
}
|
||||
} else {
|
||||
obj.insert(token_key.to_string(), json!(token));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// 至少写入一份可用的 Token
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
None => {
|
||||
// 至少写入一份可用的 Token
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
|
||||
if let Some(root) = provider.settings_config.as_object_mut()
|
||||
{
|
||||
root.insert(
|
||||
"env".to_string(),
|
||||
json!({ token_key: token }),
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||
root.insert("env".to_string(), json!({ token_key: token }));
|
||||
} else {
|
||||
log::warn!(
|
||||
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
"claude",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
) {
|
||||
log::warn!("同步 Claude Token 到数据库失败: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
"已同步 Claude Token 到数据库 (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.persist_synced_live_token(
|
||||
"claude",
|
||||
&provider_id,
|
||||
observed_fingerprint,
|
||||
provider,
|
||||
)?;
|
||||
log::info!("已同步 Claude Token 到数据库 (provider: {provider_id})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1087,55 +1108,56 @@ impl ProxyService {
|
||||
.map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?;
|
||||
|
||||
if let Some(provider_id) = provider_id {
|
||||
if let Ok(Some(mut provider)) =
|
||||
self.db.get_provider_by_id(&provider_id, "codex")
|
||||
let Some(mut provider) = self
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, "codex")
|
||||
.map_err(|error| {
|
||||
format!("读取 Codex 供应商 '{provider_id}' 失败: {error}")
|
||||
})?
|
||||
else {
|
||||
return Err(format!("Codex 当前供应商不存在: {provider_id}"));
|
||||
};
|
||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||
// The built-in official row is a routing capability, not
|
||||
// a credential store. Its auth must remain empty even
|
||||
// when the live Codex login uses OPENAI_API_KEY mode.
|
||||
if crate::proxy::providers::is_codex_official_provider(&provider) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(token) = live_config
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||
{
|
||||
// The built-in official row is a routing capability, not
|
||||
// a credential store. Its auth must remain empty even
|
||||
// when the live Codex login uses OPENAI_API_KEY mode.
|
||||
if crate::proxy::providers::is_codex_official_provider(&provider) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(token) = live_config
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||
if let Some(auth_obj) = provider
|
||||
.settings_config
|
||||
.get_mut("auth")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
if let Some(auth_obj) = provider
|
||||
.settings_config
|
||||
.get_mut("auth")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
||||
} else {
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
||||
} else {
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
|
||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||
root.insert(
|
||||
"auth".to_string(),
|
||||
json!({ "OPENAI_API_KEY": token }),
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||
root.insert("auth".to_string(), json!({ "OPENAI_API_KEY": token }));
|
||||
} else {
|
||||
log::warn!(
|
||||
"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
"codex",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
) {
|
||||
log::warn!("同步 Codex Token 到数据库失败: {e}");
|
||||
} else {
|
||||
log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})");
|
||||
}
|
||||
}
|
||||
|
||||
self.persist_synced_live_token(
|
||||
"codex",
|
||||
&provider_id,
|
||||
observed_fingerprint,
|
||||
provider,
|
||||
)?;
|
||||
log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1145,51 +1167,50 @@ impl ProxyService {
|
||||
.map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?;
|
||||
|
||||
if let Some(provider_id) = provider_id {
|
||||
if let Ok(Some(mut provider)) =
|
||||
self.db.get_provider_by_id(&provider_id, "gemini")
|
||||
let Some(mut provider) = self
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, "gemini")
|
||||
.map_err(|error| {
|
||||
format!("读取 Gemini 供应商 '{provider_id}' 失败: {error}")
|
||||
})?
|
||||
else {
|
||||
return Err(format!("Gemini 当前供应商不存在: {provider_id}"));
|
||||
};
|
||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||
if let Some(token) = live_config
|
||||
.get("env")
|
||||
.and_then(|v| v.get("GEMINI_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||
{
|
||||
if let Some(token) = live_config
|
||||
.get("env")
|
||||
.and_then(|v| v.get("GEMINI_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||
if let Some(env_obj) = provider
|
||||
.settings_config
|
||||
.get_mut("env")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
if let Some(env_obj) = provider
|
||||
.settings_config
|
||||
.get_mut("env")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
||||
} else {
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
||||
} else {
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
|
||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||
root.insert(
|
||||
"env".to_string(),
|
||||
json!({ "GEMINI_API_KEY": token }),
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||
root.insert("env".to_string(), json!({ "GEMINI_API_KEY": token }));
|
||||
} else {
|
||||
log::warn!(
|
||||
"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
"gemini",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
) {
|
||||
log::warn!("同步 Gemini Token 到数据库失败: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
"已同步 Gemini Token 到数据库 (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.persist_synced_live_token(
|
||||
"gemini",
|
||||
&provider_id,
|
||||
observed_fingerprint,
|
||||
provider,
|
||||
)?;
|
||||
log::info!("已同步 Gemini Token 到数据库 (provider: {provider_id})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1199,38 +1220,41 @@ impl ProxyService {
|
||||
.map_err(|e| format!("获取 Grok Build 当前供应商失败: {e}"))?;
|
||||
|
||||
if let Some(provider_id) = provider_id {
|
||||
if let Ok(Some(mut provider)) =
|
||||
self.db.get_provider_by_id(&provider_id, "grokbuild")
|
||||
let Some(mut provider) = self
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, "grokbuild")
|
||||
.map_err(|error| {
|
||||
format!("读取 Grok Build 供应商 '{provider_id}' 失败: {error}")
|
||||
})?
|
||||
else {
|
||||
return Err(format!("Grok Build 当前供应商不存在: {provider_id}"));
|
||||
};
|
||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||
let live_config_toml = live_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if let Some(token) =
|
||||
crate::grok_config::extract_inline_api_key(live_config_toml)
|
||||
{
|
||||
let live_config_toml = live_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if let Some(token) =
|
||||
crate::grok_config::extract_inline_api_key(live_config_toml)
|
||||
{
|
||||
if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER {
|
||||
if let Some(provider_config) = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let updated =
|
||||
crate::grok_config::update_api_key(provider_config, &token)
|
||||
.map_err(|e| {
|
||||
format!("更新 Grok Build API Key 失败: {e}")
|
||||
})?;
|
||||
provider.settings_config["config"] = json!(updated);
|
||||
self.db
|
||||
.update_provider_settings_config(
|
||||
"grokbuild",
|
||||
&provider_id,
|
||||
&provider.settings_config,
|
||||
)
|
||||
if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER {
|
||||
if let Some(provider_config) = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let updated =
|
||||
crate::grok_config::update_api_key(provider_config, &token)
|
||||
.map_err(|e| {
|
||||
format!("同步 Grok Build Token 到数据库失败: {e}")
|
||||
format!("更新 Grok Build API Key 失败: {e}")
|
||||
})?;
|
||||
}
|
||||
provider.settings_config["config"] = json!(updated);
|
||||
self.persist_synced_live_token(
|
||||
"grokbuild",
|
||||
&provider_id,
|
||||
observed_fingerprint,
|
||||
provider,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3813,7 +3837,7 @@ mod tests {
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider)
|
||||
db.reconcile_provider_fixture("claude", &provider)
|
||||
.expect("save provider");
|
||||
db.set_current_provider("claude", "p1")
|
||||
.expect("set db current provider");
|
||||
@@ -3999,7 +4023,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
provider.category = Some("cn_official".to_string());
|
||||
db.save_provider("codex", &provider)
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save DeepSeek provider");
|
||||
db.set_current_provider("codex", "deepseek")
|
||||
.expect("set current provider");
|
||||
@@ -4085,7 +4109,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
db.save_provider("codex", &provider)
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save misclassified DeepSeek provider");
|
||||
db.set_current_provider("codex", "deepseek")
|
||||
.expect("set current provider");
|
||||
@@ -4146,7 +4170,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
db.save_provider("codex", &official)
|
||||
db.reconcile_provider_fixture("codex", &official)
|
||||
.expect("save official provider");
|
||||
|
||||
let mut third_party = Provider::with_id(
|
||||
@@ -4165,7 +4189,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
third_party.category = Some("custom".to_string());
|
||||
db.save_provider("codex", &third_party)
|
||||
db.reconcile_provider_fixture("codex", &third_party)
|
||||
.expect("save third-party provider");
|
||||
db.set_current_provider("codex", "codex-official")
|
||||
.expect("set current provider");
|
||||
@@ -4314,7 +4338,8 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
db.save_provider("codex", &official).expect("save official");
|
||||
db.reconcile_provider_fixture("codex", &official)
|
||||
.expect("save official");
|
||||
db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID)
|
||||
.expect("set current");
|
||||
crate::settings::set_current_provider(
|
||||
@@ -4392,7 +4417,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
db.save_provider("codex", &provider)
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save misclassified DeepSeek provider");
|
||||
db.set_current_provider("codex", "deepseek")
|
||||
.expect("set current provider");
|
||||
@@ -4472,7 +4497,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
db.save_provider("codex", &provider)
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save misclassified DeepSeek provider");
|
||||
db.set_current_provider("codex", "deepseek")
|
||||
.expect("set current provider");
|
||||
@@ -4584,7 +4609,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
db.save_provider("codex", &provider)
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save misclassified DeepSeek provider");
|
||||
db.set_current_provider("codex", "deepseek")
|
||||
.expect("set current provider");
|
||||
@@ -4702,7 +4727,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
db.save_provider("codex", &provider)
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save misclassified DeepSeek provider");
|
||||
db.set_current_provider("codex", "deepseek")
|
||||
.expect("set current provider");
|
||||
@@ -4838,7 +4863,7 @@ wire_api = "responses"
|
||||
None,
|
||||
);
|
||||
provider.category = Some("cn_official".to_string());
|
||||
db.save_provider("codex", &provider)
|
||||
db.reconcile_provider_fixture("codex", &provider)
|
||||
.expect("save DeepSeek provider");
|
||||
db.set_current_provider("codex", "deepseek")
|
||||
.expect("set current provider");
|
||||
@@ -5315,7 +5340,7 @@ model = "gpt-5.1-codex"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider)
|
||||
db.reconcile_provider_fixture("claude", &provider)
|
||||
.expect("save provider");
|
||||
db.set_current_provider("claude", "p1")
|
||||
.expect("set current provider");
|
||||
@@ -5371,7 +5396,7 @@ model = "gpt-5.1-codex"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider)
|
||||
db.reconcile_provider_fixture("claude", &provider)
|
||||
.expect("save provider");
|
||||
db.set_current_provider("claude", "p1")
|
||||
.expect("set current provider");
|
||||
@@ -5407,6 +5432,54 @@ model = "gpt-5.1-codex"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_live_token_cannot_revert_a_concurrent_provider_edit() {
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
let provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"Original".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "stale" } }),
|
||||
None,
|
||||
);
|
||||
db.reconcile_provider_fixture("claude", &provider)
|
||||
.expect("save provider");
|
||||
|
||||
let observed = db
|
||||
.get_provider_by_id("p1", "claude")
|
||||
.expect("read observed provider")
|
||||
.expect("provider exists");
|
||||
let fingerprint = provider_row_fingerprint(&observed);
|
||||
let mut token_update = observed.clone();
|
||||
token_update.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] = json!("from-live");
|
||||
|
||||
let mut concurrent_edit = observed;
|
||||
concurrent_edit.name = "Concurrent user edit".to_string();
|
||||
if let Some(meta) = concurrent_edit.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let input = provider_to_mutation_input(concurrent_edit);
|
||||
let key = crate::database::ProviderKey::new("claude", "p1").expect("provider key");
|
||||
let row = crate::database::ProviderRowUpdate::from_input(&input).expect("row update");
|
||||
db.update_provider(&key, &row)
|
||||
.expect("persist concurrent user edit");
|
||||
|
||||
let error = service
|
||||
.persist_synced_live_token("claude", "p1", fingerprint, token_update)
|
||||
.expect_err("stale token sync must fail closed");
|
||||
assert!(error.contains("changed since it was read"), "{error}");
|
||||
|
||||
let saved = db
|
||||
.get_provider_by_id("p1", "claude")
|
||||
.expect("read saved provider")
|
||||
.expect("saved provider exists");
|
||||
assert_eq!(saved.name, "Concurrent user edit");
|
||||
assert_eq!(
|
||||
saved.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"],
|
||||
json!("stale")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn switch_proxy_target_updates_live_backup_when_taken_over() {
|
||||
@@ -5436,9 +5509,9 @@ model = "gpt-5.1-codex"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider_a)
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
@@ -5511,9 +5584,9 @@ model = "gpt-5.1-codex"
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
@@ -5662,11 +5735,11 @@ model = "gpt-5.1-codex"
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.save_provider("claude", &provider_c)
|
||||
db.reconcile_provider_fixture("claude", &provider_c)
|
||||
.expect("save provider c");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
@@ -5749,9 +5822,9 @@ model = "gpt-5.1-codex"
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
db.reconcile_provider_fixture("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
db.reconcile_provider_fixture("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
@@ -6049,9 +6122,9 @@ requires_openai_auth = true
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("codex", &provider_a)
|
||||
db.reconcile_provider_fixture("codex", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("codex", &provider_b)
|
||||
db.reconcile_provider_fixture("codex", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("codex", "a")
|
||||
.expect("set current provider");
|
||||
@@ -6224,9 +6297,9 @@ requires_openai_auth = true
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
db.save_provider("codex", &provider_a)
|
||||
db.reconcile_provider_fixture("codex", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("codex", &provider_b)
|
||||
db.reconcile_provider_fixture("codex", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("codex", "a")
|
||||
.expect("set current provider");
|
||||
@@ -6468,9 +6541,9 @@ requires_openai_auth = true
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
db.save_provider("codex", &provider_a)
|
||||
db.reconcile_provider_fixture("codex", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("codex", &provider_b)
|
||||
db.reconcile_provider_fixture("codex", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("codex", "a")
|
||||
.expect("set current provider a");
|
||||
@@ -6604,9 +6677,9 @@ requires_openai_auth = true
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("codex", &provider_a)
|
||||
db.reconcile_provider_fixture("codex", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("codex", &provider_b)
|
||||
db.reconcile_provider_fixture("codex", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("codex", "a")
|
||||
.expect("set current provider a");
|
||||
@@ -6686,9 +6759,9 @@ requires_openai_auth = true
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider_a)
|
||||
db.reconcile_provider_fixture("codex", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("codex", &provider_b)
|
||||
db.reconcile_provider_fixture("codex", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("codex", "a")
|
||||
.expect("set current provider a");
|
||||
@@ -6970,7 +7043,7 @@ requires_openai_auth = true
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider)
|
||||
db.reconcile_provider_fixture("claude", &provider)
|
||||
.expect("save provider");
|
||||
db.set_current_provider("claude", "p1")
|
||||
.expect("set current provider");
|
||||
@@ -7224,9 +7297,9 @@ experimental_bearer_token = "PROXY_MANAGED"
|
||||
grok_provider_config("https://b.example.com/v1", "b-key"),
|
||||
None,
|
||||
);
|
||||
db.save_provider("grokbuild", &provider_a)
|
||||
db.reconcile_provider_fixture("grokbuild", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("grokbuild", &provider_b)
|
||||
db.reconcile_provider_fixture("grokbuild", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("grokbuild", "grok-a")
|
||||
.expect("set db current");
|
||||
@@ -7291,9 +7364,9 @@ experimental_bearer_token = "PROXY_MANAGED"
|
||||
json!({ "config": "not valid toml = [" }),
|
||||
None,
|
||||
);
|
||||
db.save_provider("grokbuild", &provider_a)
|
||||
db.reconcile_provider_fixture("grokbuild", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("grokbuild", &provider_b)
|
||||
db.reconcile_provider_fixture("grokbuild", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("grokbuild", "grok-a")
|
||||
.expect("set db current");
|
||||
|
||||
@@ -8,11 +8,11 @@ use crate::error::AppError;
|
||||
use crate::services::skill::{SkillStorageLocation, SyncMethod};
|
||||
|
||||
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomEndpoint {
|
||||
pub url: String,
|
||||
pub added_at: i64,
|
||||
pub added_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ use std::fs;
|
||||
use serde_json::json;
|
||||
|
||||
use cc_switch_lib::{
|
||||
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService,
|
||||
Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService,
|
||||
AppType, InstalledSkill, McpServer, McpService, NewProviderAggregate, ProfilePayload,
|
||||
ProfileScope, ProfileService, Prompt, PromptService, Provider, ProviderService, SkillApps,
|
||||
SkillService,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||
use support::{create_test_state, ensure_test_home, new_provider_input, reset_test_fs, test_mutex};
|
||||
|
||||
fn claude_provider(id: &str, token: &str) -> Provider {
|
||||
Provider::with_id(
|
||||
@@ -107,34 +108,41 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||
.expect("save provider p1");
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
||||
.expect("save provider p2");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::Claude,
|
||||
new_provider_input(claude_provider("p1", "key-1")),
|
||||
false,
|
||||
)
|
||||
.expect("create provider p1");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::Claude,
|
||||
new_provider_input(claude_provider("p2", "key-2")),
|
||||
false,
|
||||
)
|
||||
.expect("create provider p2");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||
.expect("set current provider p1");
|
||||
|
||||
// Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用)
|
||||
state
|
||||
.db
|
||||
.save_provider(
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
&desktop_provider("d1", "dk-1"),
|
||||
)
|
||||
.expect("save desktop provider d1");
|
||||
state
|
||||
.db
|
||||
.save_provider(
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
&desktop_provider("d2", "dk-2"),
|
||||
)
|
||||
.expect("save desktop provider d2");
|
||||
for provider in [
|
||||
desktop_provider("d1", "dk-1"),
|
||||
desktop_provider("d2", "dk-2"),
|
||||
] {
|
||||
state
|
||||
.db
|
||||
.create_provider(
|
||||
NewProviderAggregate::from_input(
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
new_provider_input(provider),
|
||||
)
|
||||
.expect("build typed desktop create"),
|
||||
)
|
||||
.expect("create desktop provider");
|
||||
}
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||||
@@ -287,10 +295,13 @@ fn shared_profile_sides_are_isolated_and_mergeable() {
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// 种子:Claude 侧有当前供应商 + 启用的 MCP
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||
.expect("save provider p1");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::Claude,
|
||||
new_provider_input(claude_provider("p1", "key-1")),
|
||||
false,
|
||||
)
|
||||
.expect("create provider p1");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||
@@ -496,14 +507,20 @@ fn switching_profile_autosaves_previous_profile_state() {
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// ---- 种子:Claude 侧两套供应商 / MCP / Prompt ----
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||
.expect("save provider p1");
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
||||
.expect("save provider p2");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::Claude,
|
||||
new_provider_input(claude_provider("p1", "key-1")),
|
||||
false,
|
||||
)
|
||||
.expect("create provider p1");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::Claude,
|
||||
new_provider_input(claude_provider("p2", "key-2")),
|
||||
false,
|
||||
)
|
||||
.expect("create provider p2");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||
@@ -665,17 +682,13 @@ fn profile_switch_auto_disables_takeover_before_apply() {
|
||||
// ---- 两个 Claude 供应商:custom1 与 custom2 ----
|
||||
let mut custom1 = claude_provider("custom1", "custom-key-1");
|
||||
custom1.category = Some("custom".to_string());
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &custom1)
|
||||
.expect("save custom1 provider");
|
||||
ProviderService::add(&state, AppType::Claude, new_provider_input(custom1), false)
|
||||
.expect("create custom1 provider");
|
||||
|
||||
let mut custom2 = claude_provider("custom2", "custom-key-2");
|
||||
custom2.category = Some("custom".to_string());
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &custom2)
|
||||
.expect("save custom2 provider");
|
||||
ProviderService::add(&state, AppType::Claude, new_provider_input(custom2), false)
|
||||
.expect("create custom2 provider");
|
||||
|
||||
// 初始状态:custom1 + 代理接管
|
||||
ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1");
|
||||
@@ -757,20 +770,20 @@ fn claude_desktop_profile_scope_is_independent() {
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
&desktop_provider("d1", "dk-1"),
|
||||
)
|
||||
.expect("save desktop provider d1");
|
||||
state
|
||||
.db
|
||||
.save_provider(
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
&desktop_provider("d2", "dk-2"),
|
||||
)
|
||||
.expect("save desktop provider d2");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::ClaudeDesktop,
|
||||
new_provider_input(desktop_provider("d1", "dk-1")),
|
||||
false,
|
||||
)
|
||||
.expect("create desktop provider d1");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::ClaudeDesktop,
|
||||
new_provider_input(desktop_provider("d2", "dk-2")),
|
||||
false,
|
||||
)
|
||||
.expect("create desktop provider d2");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||||
|
||||
@@ -12,7 +12,7 @@ mod support;
|
||||
use std::collections::HashMap;
|
||||
use support::{
|
||||
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
||||
ensure_test_home, reset_test_fs, test_mutex,
|
||||
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
fn settings_path(home: &Path) -> PathBuf {
|
||||
@@ -64,18 +64,18 @@ fn grokbuild_import_and_switch_write_live_config() {
|
||||
);
|
||||
|
||||
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
|
||||
state
|
||||
.db
|
||||
.save_provider(
|
||||
AppType::GrokBuild.as_str(),
|
||||
&Provider::with_id(
|
||||
"relay".to_string(),
|
||||
"Relay".to_string(),
|
||||
json!({ "config": next_config }),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.expect("save second Grok Build provider");
|
||||
ProviderService::add(
|
||||
&state,
|
||||
AppType::GrokBuild,
|
||||
new_provider_input(Provider::with_id(
|
||||
"relay".to_string(),
|
||||
"Relay".to_string(),
|
||||
json!({ "config": next_config }),
|
||||
None,
|
||||
)),
|
||||
false,
|
||||
)
|
||||
.expect("create second Grok Build provider");
|
||||
|
||||
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
|
||||
.expect("switch Grok Build provider");
|
||||
|
||||
@@ -9,7 +9,7 @@ use cc_switch_lib::{
|
||||
mod support;
|
||||
use support::{
|
||||
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
||||
ensure_test_home, reset_test_fs, test_mutex,
|
||||
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
fn sanitize_provider_name(name: &str) -> String {
|
||||
@@ -3084,10 +3084,8 @@ fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_ba
|
||||
taken_over_live.clone(),
|
||||
None,
|
||||
);
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &provider)
|
||||
.expect("save placeholder provider");
|
||||
ProviderService::add(&state, AppType::Claude, new_provider_input(provider), false)
|
||||
.expect("create placeholder provider");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "default")
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
|
||||
use cc_switch_lib::{
|
||||
update_settings, AppSettings, AppState, Database, MultiAppConfig, Provider,
|
||||
ProviderMutationInput,
|
||||
};
|
||||
|
||||
/// Build the public write DTO explicitly for integration tests. Keeping this
|
||||
/// conversion test-only avoids reintroducing a production `From<Provider>`
|
||||
/// path from hydrated read projections to provider mutations.
|
||||
#[allow(dead_code)]
|
||||
pub fn new_provider_input(provider: Provider) -> ProviderMutationInput {
|
||||
ProviderMutationInput {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
settings_config: provider.settings_config,
|
||||
website_url: provider.website_url,
|
||||
category: provider.category,
|
||||
created_at: provider.created_at,
|
||||
sort_index: provider.sort_index,
|
||||
notes: provider.notes,
|
||||
meta: provider.meta,
|
||||
icon: provider.icon,
|
||||
icon_color: provider.icon_color,
|
||||
in_failover_queue: provider.in_failover_queue,
|
||||
}
|
||||
}
|
||||
|
||||
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
||||
pub fn ensure_test_home() -> &'static Path {
|
||||
|
||||
Reference in New Issue
Block a user