Compare commits

..

2 Commits

Author SHA1 Message Date
SaladDay c7d37320f5 test(provider): migrate macOS update fixture 2026-08-03 15:02:04 +00:00
SaladDay ac764b4818 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.
2026-08-03 10:01:45 +00:00
41 changed files with 5808 additions and 1190 deletions
+1
View File
@@ -799,6 +799,7 @@ dependencies = [
"serde_yaml",
"serial_test",
"sha2",
"syn 2.0.117",
"sys-locale",
"tauri",
"tauri-build",
+1
View File
@@ -116,3 +116,4 @@ strip = "symbols"
[dev-dependencies]
serial_test = "3"
tempfile = "3"
syn = { version = "2", features = ["full", "visit"] }
+52 -19
View File
@@ -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"));
+11 -4
View File
@@ -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;
}
+3
View File
@@ -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
+45 -1
View File
@@ -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)?)
}
}
}
+212 -3
View File
@@ -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 返回的模型名称标准化后一致
@@ -2237,7 +2384,6 @@ impl Database {
"0",
),
// Qwen 系列 (阿里巴巴)
("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"),
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
(
@@ -3223,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'),
@@ -3236,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(())
}
}
+2
View File
@@ -1,3 +1,5 @@
#![cfg(test)]
//! 数据库模块测试
//!
//! 包含 Schema 迁移和基本功能的测试。
+24 -16
View File
@@ -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)?;
+36 -1
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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}");
}
}
}
}
+144
View File
@@ -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")
}
+18 -9
View File
@@ -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();
// 启用自动故障转移
+20 -3
View File
@@ -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 {
+5 -1
View File
@@ -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)?;
+9 -15
View File
@@ -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
+70 -11
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+294 -221
View File
@@ -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");
+10 -87
View File
@@ -2374,38 +2374,31 @@ impl SkillService {
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
///
/// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序
/// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先
/// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
/// 3. 兜底:仓库根本身含 `SKILL.md`
/// 兼容三种情况
/// 1. `skills/foo` 这类直接相对路径
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
let install_name = source_rel
.file_name()
.map(|n| n.to_string_lossy().to_string())?;
// 1. 直接相对路径命中(明确路径优先)——必须校验 SKILL.md,否则同名空壳目录
// (如 ast-grep/agent-skill 根下的 plugin 包目录 ast-grep/)会被误判为源目录。
let direct = root.join(&source_rel);
if direct.is_dir() && direct.join("SKILL.md").is_file() {
if direct.is_dir() {
return Some(direct);
}
// 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md
if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}",
install_name,
target_name,
found.display()
);
return Some(found);
}
// 3. 兜底:仓库根本身是 skill
if root.join("SKILL.md").is_file() {
if root.is_dir() && root.join("SKILL.md").exists() {
log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
install_name,
target_name,
);
return Some(root.to_path_buf());
}
@@ -4459,74 +4452,4 @@ mod tests {
"existing destination skill should be preserved"
);
}
#[test]
fn resolve_skill_source_dir_rejects_same_name_wrapper_without_skill_md() {
// 复刻 issue #4141ast-grep/agent-skill 结构。仓库根下有同名目录 ast-grep/
// plugin 包,无 SKILL.md),真正的 skill 在 ast-grep/skills/ast-grep/SKILL.md。
let temp = tempdir().expect("tempdir");
let wrapper = temp.path().join("ast-grep");
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
fs::write(
wrapper.join(".claude-plugin").join("plugin.json"),
"{\"name\":\"ast-grep\"}",
)
.expect("write plugin.json");
let real_skill = wrapper.join("skills").join("ast-grep");
write_skill(&real_skill, "ast-grep");
// directory 只给了 skill 名 "ast-grep"skills.sh API 的语义),不能命中空壳 wrapper。
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep")
.expect("should resolve to the inner skill dir, not the same-name wrapper");
assert_eq!(resolved, real_skill);
assert!(resolved.join("SKILL.md").is_file());
}
#[test]
fn resolve_skill_source_dir_finds_two_level_catalog_skill() {
// catalog layoutskills/category/foo/SKILL.mddepth 3find_skill_dir_by_name 可达)。
let temp = tempdir().expect("tempdir");
let catalog_skill = temp.path().join("skills").join("category").join("foo");
write_skill(&catalog_skill, "Foo Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo")
.expect("should resolve the two-level catalog skill by name");
assert_eq!(resolved, catalog_skill);
}
#[test]
fn resolve_skill_source_dir_returns_none_for_wrapper_without_inner_skill() {
// 同名 wrapper 存在、无 SKILL.md,且无 inner skill / root SKILL.md 可兜底时,
// 必须返回 None——守住 #4141 这个 bug class 的负例(不能把空壳目录当源目录)。
let temp = tempdir().expect("tempdir");
let wrapper = temp.path().join("ast-grep");
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
fs::write(
wrapper.join(".claude-plugin").join("plugin.json"),
"{\"name\":\"ast-grep\"}",
)
.expect("write plugin.json");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep");
assert!(
resolved.is_none(),
"wrapper dir without SKILL.md and no inner skill must resolve to None, got {:?}",
resolved
);
}
#[test]
fn resolve_skill_source_dir_returns_none_when_no_skill_md_anywhere() {
let temp = tempdir().expect("tempdir");
fs::create_dir_all(temp.path().join("skills").join("foo")).expect("create empty skill dir");
fs::write(temp.path().join("README.md"), "no skills here").expect("write README");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo");
assert!(
resolved.is_none(),
"no SKILL.md anywhere must resolve to None"
);
}
}
+2 -2
View File
@@ -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>,
}
+72 -59
View File
@@ -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")
+13 -13
View File
@@ -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");
+3 -5
View File
@@ -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")
+25 -1
View File
@@ -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 {
-55
View File
@@ -1,55 +0,0 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription";
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
interface CodexOauthAccountQuotaProps {
/** cc-switch 自管的 ChatGPT 账号 ID */
accountId: string;
}
/**
* ChatGPT (Codex OAuth)
*
* accountId cc-switch OAuth token
* `SubscriptionQuotaView` + +
*
*
*
*/
const CodexOauthAccountQuota: React.FC<CodexOauthAccountQuotaProps> = ({
accountId,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useCodexOauthQuotaByAccountId(accountId, {
enabled: true,
autoQuery: false,
});
// 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。
// 用量请求是异步的(Tauri invoke + React Query),加载期间给一个
// 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位,
// 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。
if (loading && !quota) {
return (
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint="codex_oauth"
inline={false}
/>
);
};
export default CodexOauthAccountQuota;
@@ -24,12 +24,9 @@ import {
} from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard";
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
interface CodexOAuthSectionProps {
className?: string;
/** 是否展示每个账号的订阅额度 */
showAccountQuota?: boolean;
/** 当前选中的 ChatGPT 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
@@ -48,7 +45,6 @@ interface CodexOAuthSectionProps {
*/
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
showAccountQuota = false,
selectedAccountId,
onAccountSelect,
fastModeEnabled = false,
@@ -182,52 +178,47 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{accounts.map((account) => (
<div
key={account.id}
className="space-y-2 p-2 rounded-md border bg-muted/30"
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
{t("codexOauth.setAsDefault", "设为默认")}
</Button>
)}
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
<X className="h-4 w-4" />
{t("codexOauth.setAsDefault", "设为默认")}
</Button>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
{showAccountQuota && (
<CodexOauthAccountQuota accountId={account.id} />
)}
</div>
))}
</div>
@@ -1537,8 +1537,16 @@ function ProviderFormFull({
}
}
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
const metaSource = payload.meta ?? initialData?.meta;
const baseMeta: ProviderMeta | undefined = metaSource
? { ...metaSource }
: undefined;
// Existing-provider edits never own endpoint membership. The backend
// rejects endpoint-bearing update payloads; add/remove/touch use their
// dedicated commands and remain safe from stale form snapshots.
if (isEditMode && baseMeta) {
delete baseMeta.custom_endpoints;
}
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType = presetProviderType || initialData?.meta?.providerType;
+1 -1
View File
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
</div>
</div>
<CodexOAuthSection showAccountQuota />
<CodexOAuthSection />
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
+5 -1
View File
@@ -30,6 +30,7 @@ import {
supportsOfficialProxyTakeover,
} from "@/utils/providerCapabilities";
import { isOAuthProviderType } from "@/config/constants";
import { toProviderUpdateInput } from "@/lib/api/providers";
/**
* Hook for managing provider actions (add, update, delete, switch)
@@ -362,7 +363,10 @@ export function useProviderActions(
},
};
await providersApi.update(updatedProvider, activeApp);
await providersApi.update(
toProviderUpdateInput(updatedProvider),
activeApp,
);
await queryClient.invalidateQueries({
queryKey: ["providers", activeApp],
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { Provider } from "@/types";
import { toProviderUpdateInput } from "./providers";
describe("toProviderUpdateInput", () => {
it("removes hydrated endpoints and row-state fields from update payloads", () => {
const hydrated: Provider = {
id: "endpoint-provider",
name: "Endpoint provider",
settingsConfig: { env: { API_KEY: "secret" } },
createdAt: 1_700_000_000,
sortIndex: 7,
inFailoverQueue: true,
meta: {
custom_endpoints: {
"https://one.example": {
url: "https://one.example",
addedAt: null,
},
},
usage_script: {
enabled: true,
language: "javascript",
code: "{}",
},
},
};
const update = toProviderUpdateInput(hydrated);
expect(update).not.toHaveProperty("createdAt");
expect(update).not.toHaveProperty("sortIndex");
expect(update).not.toHaveProperty("inFailoverQueue");
expect(update.meta).not.toHaveProperty("custom_endpoints");
expect(update.meta?.usage_script).toEqual(hydrated.meta?.usage_script);
expect(
hydrated.meta?.custom_endpoints?.["https://one.example"].addedAt,
).toBeNull();
});
});
+35 -1
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
Provider,
ProviderMeta,
UniversalProvider,
UniversalProvidersMap,
} from "@/types";
@@ -12,6 +13,39 @@ export interface ProviderSortUpdate {
sortIndex: number;
}
export type ProviderUpdateMeta = Omit<ProviderMeta, "custom_endpoints"> & {
custom_endpoints?: never;
};
export type ProviderUpdateInput = Omit<
Provider,
"createdAt" | "sortIndex" | "inFailoverQueue" | "meta"
> & {
meta?: ProviderUpdateMeta;
};
export function toProviderUpdateInput(provider: Provider): ProviderUpdateInput {
let meta: ProviderUpdateMeta | undefined;
if (provider.meta) {
const rowMeta = { ...provider.meta };
delete rowMeta.custom_endpoints;
meta = rowMeta as ProviderUpdateMeta;
}
return {
id: provider.id,
name: provider.name,
settingsConfig: provider.settingsConfig,
websiteUrl: provider.websiteUrl,
category: provider.category,
notes: provider.notes,
isPartner: provider.isPartner,
meta,
icon: provider.icon,
iconColor: provider.iconColor,
};
}
export interface ProviderSwitchEvent {
appType: AppId;
providerId: string;
@@ -64,7 +98,7 @@ export const providersApi = {
},
async update(
provider: Provider,
provider: ProviderUpdateInput,
appId: AppId,
originalId?: string,
): Promise<boolean> {
+6 -2
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
import type { DeleteSessionOptions } from "@/lib/api/sessions";
import type { SwitchResult } from "@/lib/api/providers";
import { toProviderUpdateInput, type SwitchResult } from "@/lib/api/providers";
import type { Provider, SessionMeta, Settings } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
import { generateUUID } from "@/utils/uuid";
@@ -168,7 +168,11 @@ export const useUpdateProviderMutation = (appId: AppId) => {
provider: Provider;
originalId?: string;
}) => {
await providersApi.update(provider, appId, originalId);
await providersApi.update(
toProviderUpdateInput(provider),
appId,
originalId,
);
return provider;
},
onSuccess: async (provider, variables) => {
+8 -21
View File
@@ -112,18 +112,20 @@ export interface UseCodexOauthQuotaOptions {
}
/**
* Codex OAuth hook ID
* Codex OAuth (ChatGPT Plus/Pro ) hook
*
* cc-switch ChatGPT ID
* Query key `useCodexOauthQuota`
*
* `useSubscriptionQuota` cc-switch OAuth token
* Codex CLI ~/.codex/auth.json
*
* Query key accountId
* accountId null 使 "default" fallback
*/
export function useCodexOauthQuotaByAccountId(
accountId: string | null,
export function useCodexOauthQuota(
meta: ProviderMeta | undefined,
options: UseCodexOauthQuotaOptions = {},
) {
const { enabled = true, autoQuery = false } = options;
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
const query = useQuery({
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
@@ -138,21 +140,6 @@ export function useCodexOauthQuotaByAccountId(
return useQuotaKeepLastGood(query, accountId ?? "default");
}
/**
* Codex OAuth (ChatGPT Plus/Pro ) hook
*
* `useSubscriptionQuota` cc-switch OAuth token
* Codex CLI ~/.codex/auth.json ID meta
* authBinding `useCodexOauthQuotaByAccountId`
*/
export function useCodexOauthQuota(
meta: ProviderMeta | undefined,
options: UseCodexOauthQuotaOptions = {},
) {
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
return useCodexOauthQuotaByAccountId(accountId, options);
}
/**
* xAI OAuth (SuperGrok ) hook
*
+1 -1
View File
@@ -38,7 +38,7 @@ export interface AppConfig {
// 自定义端点配置
export interface CustomEndpoint {
url: string;
addedAt: number;
addedAt: number | null;
lastUsed?: number;
}
@@ -1,74 +0,0 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
const mocks = vi.hoisted(() => ({
useCodexOauth: vi.fn(),
renderAccountQuota: vi.fn(),
}));
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
useCodexOauth: mocks.useCodexOauth,
}));
vi.mock("@/components/CodexOauthAccountQuota", () => ({
default: ({ accountId }: { accountId: string }) => {
mocks.renderAccountQuota(accountId);
return <div data-testid="account-quota">{accountId}</div>;
},
}));
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
CopilotAuthSection: () => <div />,
}));
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
XaiOAuthSection: () => <div />,
}));
describe("CodexOAuthSection", () => {
beforeEach(() => {
mocks.useCodexOauth.mockReturnValue({
accounts: [
{
id: "account-1",
provider: "codex_oauth",
login: "user@example.com",
avatar_url: null,
authenticated_at: 0,
is_default: true,
github_domain: "",
},
],
defaultAccountId: "account-1",
hasAnyAccount: true,
pollingState: "idle",
deviceCode: null,
error: null,
isPolling: false,
isAddingAccount: false,
isRemovingAccount: false,
isSettingDefaultAccount: false,
addAccount: vi.fn(),
removeAccount: vi.fn(),
setDefaultAccount: vi.fn(),
cancelAuth: vi.fn(),
logout: vi.fn(),
});
});
it("does not render account quota by default", () => {
render(<CodexOAuthSection />);
expect(mocks.renderAccountQuota).not.toHaveBeenCalled();
expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument();
});
it("renders account quota in Auth Center", () => {
render(<AuthCenterPanel />);
expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1");
expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1");
});
});
+35
View File
@@ -0,0 +1,35 @@
{
"manifestVersion": 1,
"codeAuthority": "src-tauri/src/database/dao/provider_write.rs",
"types": {
"ProviderKey": ["app_type", "id"],
"ProviderRowCreate": ["content", "created_at"],
"ProviderRowUpdate": [
"name",
"settings_config",
"website_url",
"category",
"notes",
"meta",
"icon",
"icon_color"
],
"NewEndpoint": ["url", "added_at", "last_used"],
"NewProviderAggregate": [
"key",
"row",
"sort_index",
"in_failover_queue",
"initial_endpoints"
],
"RenameProvider": ["source", "target_id", "row"]
},
"databaseMethods": {
"create_provider": ["NewProviderAggregate"],
"update_provider": ["ProviderKey", "ProviderRowUpdate"],
"rename_db_only_additive_provider": ["RenameProvider"],
"add_provider_endpoint": ["ProviderKey", "NewEndpoint"],
"remove_provider_endpoint": ["ProviderKey", "str"],
"touch_provider_endpoint": ["ProviderKey", "str", "i64"]
}
}