Compare commits

..

7 Commits

Author SHA1 Message Date
Jason e8e4cae41b Merge origin/main into feat/codex-oauth-account-usage 2026-08-03 21:15:48 +08:00
makoMakoGo eb356e15bd fix(skills): resolve source dir by SKILL.md anchor instead of name (#4153)
* fix(skills): resolve source dir by SKILL.md anchor instead of name

resolve_skill_source_dir previously guessed the source dir via root.join(name).is_dir() without verifying SKILL.md, misjudging same-name non-skill dirs (e.g. the ast-grep plugin wrapper dir in ast-grep/agent-skill) and causing install failure #4141.

Now anchors on SKILL.md: direct + SKILL.md check -> root manifest explicit skills[] -> fallback by name -> root fallback. Adds 5 layout tests.

Closes #4141

* fix(skills): drop speculative manifest resolver path

resolve_via_manifest (parsing root .claude-plugin/marketplace.json &
plugin.json explicit skills[]) is inert for the actual #4141 case: the
real ast-grep/agent-skill marketplace.json declares no skills[] array,
so the manifest branch never produces a candidate. The #4141 fix is
delivered entirely by resolve_skill_source_dir step 1's SKILL.md anchor
plus the pre-existing find_skill_dir_by_name DFS.

Keeping the manifest path would pull npx-skills package-parity semantics
(pluginRoot / source / remote-object source / skills[] / "./"-validation
/ ...) into a bug hotfix, with no real manifest proving it is not dead
code. Drop it to keep this PR a focused #4141 hotfix.

- remove SkillMarketplaceMetadata / SkillManifestPlugin /
  SkillMarketplaceManifest, resolve_via_manifest, sanitize_manifest_path
- narrow resolve_skill_source_dir to 3 steps
  (direct+SKILL.md -> by-name DFS+SKILL.md -> root+SKILL.md -> None)
- replace the two synthetic manifest tests with a negative case:
  same-name wrapper dir without SKILL.md and no inner skill -> None

cargo test --lib resolve_skill_source_dir: 7 passed
cargo clippy --lib: clean
2026-08-03 19:05:51 +08:00
mhy1227 f38722a440 feat(pricing): seed Qwen3.8 Max built-in model pricing (#6053)
* feat(pricing): seed Qwen3.8 Max built-in model pricing

Add insert-if-absent row for qwen3.8-max at 2/6 USD per Mtok input/output with 0.20 cache read.

* fix(pricing): set qwen3.8-max cache write to 2.50

Align cache_write with official explicit context-cache rate (125 percent of input). cache_read stays 0.20 (10 percent hit).

* fix(pricing): correct qwen3.8-max cache read price

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-08-03 17:57:24 +08:00
saladday bc180a3d9d fix(codex-oauth): scope account quota to auth center 2026-07-14 02:57:35 -04:00
saladday c7a2bff78b Merge remote-tracking branch 'origin/main' into pr-4887
# Conflicts:
#	src/lib/query/subscription.ts
2026-07-14 02:56:31 -04:00
SaladDay d52ab6c5f4 refactor(codex-oauth): stable async loading placeholder for account usage
The account header (login + badges + actions) already renders independently
of the usage query — the quota is fetched async via Tauri invoke + React
Query, so the account never waits on it. Make that visually obvious and
jump-free: while the usage loads, show a spinner inside a placeholder shaped
like the final quota card (same rounded-xl / border / bg-card), so the card
morphs smoothly into the data instead of popping in from an empty gap.
2026-07-01 17:33:35 +00:00
SaladDay 0f3991efc3 feat(codex-oauth): show per-account usage in Auth Center
Each ChatGPT (Codex OAuth) account under Settings → 认证 now displays its
own subscription usage — reset countdowns and per-window progress bars —
directly in the account list, instead of usage only being visible on the
active provider card.

- Add useCodexOauthQuotaByAccountId(accountId) and refactor
  useCodexOauthQuota to delegate to it (shared query key → cache reuse)
- Add CodexOauthAccountQuota, a thin per-account wrapper that reuses the
  existing SubscriptionQuotaView expanded layout (same look and 5-state
  handling as provider cards), with a light spinner on first load
- Render it under each account row in CodexOAuthSection; fetch once when
  the Auth Center opens, manual refresh available (no polling)

Copilot is intentionally left out — same as before, this is Codex-only.
2026-07-01 17:07:04 +00:00
41 changed files with 1190 additions and 5808 deletions
-1
View File
@@ -799,7 +799,6 @@ dependencies = [
"serde_yaml", "serde_yaml",
"serial_test", "serial_test",
"sha2", "sha2",
"syn 2.0.117",
"sys-locale", "sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
-1
View File
@@ -116,4 +116,3 @@ strip = "symbols"
[dev-dependencies] [dev-dependencies]
serial_test = "3" serial_test = "3"
tempfile = "3" tempfile = "3"
syn = { version = "2", features = ["full", "visit"] }
+19 -52
View File
@@ -10,10 +10,6 @@ use crate::codex_state_db::codex_state_db_paths;
use crate::config::{atomic_write, copy_file, get_app_config_dir}; use crate::config::{atomic_write, copy_file, get_app_config_dir};
use crate::database::{is_official_seed_id, Database}; use crate::database::{is_official_seed_id, Database};
use crate::error::AppError; use crate::error::AppError;
use crate::services::provider::{
provider_row_fingerprint, provider_to_mutation_input,
reconcile_provider_record_with_precondition, ReconcilePrecondition,
};
use crate::settings::{ use crate::settings::{
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration, CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
CodexThirdPartyHistoryProviderBucketMigration, CodexThirdPartyHistoryProviderBucketMigration,
@@ -667,8 +663,7 @@ fn migrate_codex_provider_templates_to_custom(
let providers = db.get_all_providers("codex")?; let providers = db.get_all_providers("codex")?;
let mut migrated_provider_ids = Vec::new(); let mut migrated_provider_ids = Vec::new();
for (_, mut provider) in providers { for (_, provider) in providers {
let observed_fingerprint = provider_row_fingerprint(&provider);
if provider.category.as_deref() == Some("official") if provider.category.as_deref() == Some("official")
|| is_official_seed_id(&provider.id) || is_official_seed_id(&provider.id)
|| provider.is_codex_oauth() || provider.is_codex_oauth()
@@ -699,21 +694,8 @@ fn migrate_codex_provider_templates_to_custom(
}; };
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?; backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
obj.insert("config".to_string(), Value::String(migrated_config_text)); obj.insert("config".to_string(), Value::String(migrated_config_text));
let provider_id = provider.id.clone(); db.update_provider_settings_config("codex", &provider.id, &settings)?;
provider.settings_config = settings; migrated_provider_ids.push(provider.id);
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 { Ok(CodexProviderTemplateBucketMigrationOutcome {
@@ -1457,8 +1439,7 @@ base_url = "https://proxy.example/v1"
), ),
]; ];
for provider in providers { for provider in providers {
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
} }
let mut official = Provider::with_id( let mut official = Provider::with_id(
@@ -1468,8 +1449,7 @@ base_url = "https://proxy.example/v1"
None, None,
); );
official.category = Some("official".to_string()); official.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &official) db.save_provider("codex", &official).expect("save official");
.expect("save official");
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids"); let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert_eq!( assert_eq!(
@@ -2191,10 +2171,9 @@ base_url = "https://proxy.example/v1"
); );
official.category = Some("official".to_string()); official.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &third_party) db.save_provider("codex", &third_party)
.expect("save third-party"); .expect("save third-party");
db.reconcile_provider_fixture("codex", &official) db.save_provider("codex", &official).expect("save official");
.expect("save official");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("rightcode")); assert!(ids.contains("rightcode"));
@@ -2217,8 +2196,7 @@ base_url = "https://proxy.example/v1"
); );
provider.category = Some("aggregator".to_string()); provider.category = Some("aggregator".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay")); assert!(!ids.contains("my-private-relay"));
@@ -2238,8 +2216,7 @@ base_url = "https://proxy.example/v1"
); );
provider.category = Some("aggregator".to_string()); provider.category = Some("aggregator".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay")); assert!(!ids.contains("my-private-relay"));
@@ -2267,8 +2244,7 @@ model_provider = "my-private-relay"
); );
provider.category = Some("aggregator".to_string()); provider.category = Some("aggregator".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay")); assert!(!ids.contains("my-private-relay"));
@@ -2288,8 +2264,7 @@ model_provider = "my-private-relay"
); );
provider.category = Some("aggregator".to_string()); provider.category = Some("aggregator".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("aihubmix")); assert!(ids.contains("aihubmix"));
@@ -2310,8 +2285,7 @@ model_provider = "my-private-relay"
); );
provider.category = Some("aggregator".to_string()); provider.category = Some("aggregator".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("ccswitch")); assert!(ids.contains("ccswitch"));
@@ -2343,8 +2317,7 @@ model = "gpt-5.4"
}), }),
None, None,
); );
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db); let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]); assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
@@ -2417,8 +2390,7 @@ base_url = "https://aihubmix.example/v1"
}), }),
None, None,
); );
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db); let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!( assert_eq!(
@@ -2474,8 +2446,7 @@ base_url = "http://localhost:8080/v1"
}), }),
None, None,
); );
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db); let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert!(outcome.migrated_provider_ids.is_empty()); assert!(outcome.migrated_provider_ids.is_empty());
@@ -2524,8 +2495,7 @@ base_url = "https://proxy.example/v1"
}), }),
None, None,
); );
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db); let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert!(outcome.migrated_provider_ids.is_empty()); assert!(outcome.migrated_provider_ids.is_empty());
@@ -2582,8 +2552,7 @@ model_provider = "aihubmix"
}), }),
None, None,
); );
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db); let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]); assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
@@ -2632,8 +2601,7 @@ model_provider = "aihubmix"
provider.category = Some("custom".to_string()); provider.category = Some("custom".to_string());
provider.created_at = Some(1); provider.created_at = Some(1);
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay")); assert!(!ids.contains("my-private-relay"));
@@ -2654,8 +2622,7 @@ model_provider = "aihubmix"
); );
provider.category = Some("custom".to_string()); provider.category = Some("custom".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider).expect("save provider");
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids"); let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-local-relay")); assert!(!ids.contains("my-local-relay"));
+4 -11
View File
@@ -4,9 +4,8 @@ use tauri::{Emitter, Manager, State};
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState; use crate::commands::copilot::CopilotAuthState;
use crate::commands::xai_oauth::XaiOAuthState; use crate::commands::xai_oauth::XaiOAuthState;
use crate::database::NewProviderAggregate;
use crate::error::AppError; use crate::error::AppError;
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMutationInput}; use crate::provider::{ClaudeDesktopMode, Provider};
use crate::services::{ use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult, EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
}; };
@@ -40,7 +39,7 @@ pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<S
pub fn add_provider( pub fn add_provider(
state: State<'_, AppState>, state: State<'_, AppState>,
app: String, app: String,
provider: ProviderMutationInput, provider: Provider,
#[allow(non_snake_case)] addToLive: Option<bool>, #[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
@@ -52,7 +51,7 @@ pub fn add_provider(
pub fn update_provider( pub fn update_provider(
state: State<'_, AppState>, state: State<'_, AppState>,
app: String, app: String,
provider: ProviderMutationInput, provider: Provider,
#[allow(non_snake_case)] originalId: Option<String>, #[allow(non_snake_case)] originalId: Option<String>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
@@ -251,13 +250,7 @@ pub fn import_claude_desktop_providers_from_claude(
state state
.db .db
.create_provider( .save_provider(AppType::ClaudeDesktop.as_str(), &desktop_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())?; .map_err(|e| e.to_string())?;
imported += 1; imported += 1;
} }
-3
View File
@@ -6,9 +6,6 @@ pub mod failover;
pub mod mcp; pub mod mcp;
pub mod profiles; pub mod profiles;
pub mod prompts; pub mod prompts;
pub mod provider_write;
#[cfg(test)]
mod provider_write_certification;
pub mod providers; pub mod providers;
pub mod providers_seed; pub mod providers_seed;
pub mod proxy; pub mod proxy;
@@ -1,640 +0,0 @@
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
+1 -45
View File
@@ -32,9 +32,6 @@ mod schema;
mod tests; mod tests;
// DAO 类型导出供外部使用 // DAO 类型导出供外部使用
pub use dao::provider_write::{
NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
};
pub(crate) use dao::providers_seed::{ pub(crate) use dao::providers_seed::{
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID, is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
GROKBUILD_OFFICIAL_PROVIDER_ID, GROKBUILD_OFFICIAL_PROVIDER_ID,
@@ -56,7 +53,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号 /// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 17; pub(crate) const SCHEMA_VERSION: i32 = 16;
/// 安全地序列化 JSON,避免 unwrap panic /// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> { pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
@@ -200,11 +197,6 @@ impl Database {
conn: Mutex::new(conn), conn: Mutex::new(conn),
}; };
db.create_tables()?; 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()?; db.ensure_model_pricing_seeded()?;
Ok(db) Ok(db)
@@ -301,39 +293,3 @@ impl Database {
Ok(count == 0) 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)?)
}
}
}
+3 -212
View File
@@ -53,10 +53,7 @@ impl Database {
app_type TEXT NOT NULL, app_type TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
added_at INTEGER, added_at INTEGER,
last_used INTEGER, FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
FOREIGN KEY (provider_id, app_type)
REFERENCES providers(id, app_type) ON DELETE CASCADE,
UNIQUE (provider_id, app_type, url)
)", )",
[], [],
) )
@@ -100,7 +97,6 @@ impl Database {
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes 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, installed_at INTEGER NOT NULL DEFAULT 0,
content_hash TEXT, content_hash TEXT,
updated_at INTEGER NOT NULL DEFAULT 0 updated_at INTEGER NOT NULL DEFAULT 0
@@ -109,36 +105,6 @@ impl Database {
) )
.map_err(|e| AppError::Database(e.to_string()))?; .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 表 // 6. Skill Repos 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS skill_repos ( "CREATE TABLE IF NOT EXISTS skill_repos (
@@ -545,13 +511,6 @@ impl Database {
Self::migrate_v15_to_v16(conn)?; Self::migrate_v15_to_v16(conn)?;
Self::set_user_version(conn, 16)?; 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!( return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1564,112 +1523,6 @@ impl Database {
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir) 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, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -2384,6 +2237,7 @@ impl Database {
"0", "0",
), ),
// Qwen 系列 (阿里巴巴) // 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-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"), ("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
( (
@@ -3369,7 +3223,7 @@ mod tests {
Database::apply_schema_migrations_on_conn(&conn)?; Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION); assert_eq!(Database::get_user_version(&conn)?, 16);
let counts: (i64, i64, i64, i64) = conn.query_row( let counts: (i64, i64, i64, i64) = conn.query_row(
"SELECT "SELECT
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'), (SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
@@ -3382,67 +3236,4 @@ mod tests {
assert_eq!(counts, (0, 1, 0, 1)); assert_eq!(counts, (0, 1, 0, 1));
Ok(()) 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,5 +1,3 @@
#![cfg(test)]
//! 数据库模块测试 //! 数据库模块测试
//! //!
//! 包含 Schema 迁移和基本功能的测试。 //! 包含 Schema 迁移和基本功能的测试。
+16 -24
View File
@@ -109,35 +109,27 @@ pub fn import_provider_from_deeplink(
let provider_id = provider.id.clone(); let provider_id = provider.id.clone();
// All endpoints supplied by one import request belong to the same create // Use ProviderService to add the provider
// intent. Put the non-primary endpoints into the initial aggregate so the ProviderService::add(state, app_type.clone(), provider, true)?;
// provider row and its complete endpoint set commit atomically.
let initial_endpoints = &mut provider // Add extra endpoints as custom endpoints (skip first one as it's the primary)
.meta for ep in all_endpoints.iter().skip(1) {
.get_or_insert_with(ProviderMeta::default) let normalized = ep.trim().trim_end_matches('/').to_string();
.custom_endpoints;
for endpoint in all_endpoints.iter().skip(1) {
let normalized = endpoint.trim().trim_end_matches('/').to_string();
if !normalized.is_empty() { if !normalized.is_empty() {
initial_endpoints.insert( if let Err(e) = ProviderService::add_custom_endpoint(
state,
app_type.clone(),
&provider_id,
normalized.clone(), normalized.clone(),
crate::settings::CustomEndpoint { ) {
url: normalized, log::warn!(
added_at: Some(timestamp), "Failed to add custom endpoint '{}': {e}",
last_used: None, crate::url_for_log(&normalized)
}, );
); }
} }
} }
// 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 enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) { if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?; ProviderService::switch(state, app_type.clone(), &provider_id)?;
+1 -36
View File
@@ -1,11 +1,9 @@
#![cfg(test)]
//! Deep link module tests //! Deep link module tests
use super::mcp::parse_mcp_apps; use super::mcp::parse_mcp_apps;
use super::parser::parse_deeplink_url; use super::parser::parse_deeplink_url;
use super::prompt::import_prompt_from_deeplink; use super::prompt::import_prompt_from_deeplink;
use super::provider::{import_provider_from_deeplink, parse_and_merge_config}; use super::provider::parse_and_merge_config;
use super::utils::{infer_homepage_from_endpoint, validate_url}; use super::utils::{infer_homepage_from_endpoint, validate_url};
use super::DeepLinkImportRequest; use super::DeepLinkImportRequest;
use crate::AppType; use crate::AppType;
@@ -954,39 +952,6 @@ fn test_parse_multiple_endpoints_comma_separated() {
assert!(endpoint.contains("https://api3.example.com")); 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] #[test]
fn test_parse_single_endpoint_backward_compatible() { fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work // Old format with single endpoint should still work
-7
View File
@@ -9,13 +9,6 @@ pub enum AppError {
Config(String), Config(String),
#[error("无效输入: {0}")] #[error("无效输入: {0}")]
InvalidInput(String), InvalidInput(String),
#[error("未找到: {0}")]
NotFound(String),
/// 结构化冲突:并发前置期望失败(如 reconcile 的 ExpectAbsent 撞上竞争
/// 创建、ExpectPresent 的指纹过期)。调用方据此重读重试或上浮,不得解析
/// Database(String) 文本。由前置工程 A 认证契约引入(T9)。
#[error("并发冲突: {0}")]
Conflict(String),
#[error("IO 错误: {path}: {source}")] #[error("IO 错误: {path}: {source}")]
Io { Io {
path: String, path: String,
+4 -11
View File
@@ -45,10 +45,7 @@ pub use codex_config::{
pub use commands::open_provider_terminal; pub use commands::open_provider_terminal;
pub use commands::*; pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::{ pub use database::{Database, Profile};
Database, NewEndpoint, NewProviderAggregate, Profile, ProviderKey, ProviderRowUpdate,
RenameProvider,
};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError; pub use error::AppError;
pub use grok_config::get_grok_config_path; pub use grok_config::get_grok_config_path;
@@ -60,7 +57,7 @@ pub use mcp::{
sync_single_server_to_gemini, sync_single_server_to_grokbuild, sync_single_server_to_gemini, sync_single_server_to_grokbuild,
}; };
pub use prompt::Prompt; pub use prompt::Prompt;
pub use provider::{Provider, ProviderAggregate, ProviderMeta, ProviderMutationInput}; pub use provider::{Provider, ProviderMeta};
pub use services::{ pub use services::{
profile::{ProfilePayload, ProfileScope, ProfileService}, profile::{ProfilePayload, ProfileScope, ProfileService},
provider::reapply_current_codex_official_live, provider::reapply_current_codex_official_live,
@@ -1991,7 +1988,6 @@ fn initialize_common_config_snippets(state: &store::AppState) {
.unwrap_or(true); .unwrap_or(true);
if should_run_legacy_migration { if should_run_legacy_migration {
let mut legacy_migration_succeeded = true;
for app_type in [ for app_type in [
crate::app_config::AppType::Claude, crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex, crate::app_config::AppType::Codex,
@@ -2005,14 +2001,11 @@ fn initialize_common_config_snippets(state: &store::AppState) {
"✗ Failed to migrate legacy common-config usage for {}: {e}", "✗ Failed to migrate legacy common-config usage for {}: {e}",
app_type.as_str() app_type.as_str()
); );
legacy_migration_succeeded = false;
} }
} }
if legacy_migration_succeeded { if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
if let Err(e) = state.db.set_legacy_common_config_migrated(true) { log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
}
} }
} }
} }
-144
View File
@@ -43,84 +43,6 @@ pub struct Provider {
pub in_failover_queue: bool, 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 { impl Provider {
/// 从现有ID创建供应商 /// 从现有ID创建供应商
pub fn with_id( pub fn with_id(
@@ -145,72 +67,6 @@ 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 { pub fn is_codex_oauth(&self) -> bool {
self.provider_type() == Some("codex_oauth") self.provider_type() == Some("codex_oauth")
} }
+9 -18
View File
@@ -348,10 +348,8 @@ mod tests {
let provider_b = let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None); Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a).unwrap();
.unwrap(); db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap(); db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap(); db.add_to_failover_queue("claude", "b").unwrap();
@@ -376,10 +374,8 @@ mod tests {
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None); Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1); provider_b.sort_index = Some(1);
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a).unwrap();
.unwrap(); db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap(); db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap(); db.add_to_failover_queue("claude", "b").unwrap();
@@ -411,10 +407,8 @@ mod tests {
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None); Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1); provider_b.sort_index = Some(1);
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a).unwrap();
.unwrap(); db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap(); db.set_current_provider("claude", "a").unwrap();
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置) // 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
@@ -450,10 +444,8 @@ mod tests {
let provider_b = let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None); Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a).unwrap();
.unwrap(); db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.add_to_failover_queue("claude", "a").unwrap(); db.add_to_failover_queue("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap(); db.add_to_failover_queue("claude", "b").unwrap();
@@ -493,8 +485,7 @@ mod tests {
let provider_a = let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None); Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a).unwrap();
.unwrap();
db.add_to_failover_queue("claude", "a").unwrap(); db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移 // 启用自动故障转移
+3 -20
View File
@@ -4,32 +4,15 @@
//! 防止并发切换导致 is_current 与 Live 备份不一致。 //! 防止并发切换导致 is_current 与 Live 备份不一致。
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, OnceLock}; use std::sync::Arc;
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock}; use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
type PerAppLocks = Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>;
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。 /// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
/// ///
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。 /// 不同应用之间(如 Claude 和 Codex)可以并行切换。
#[derive(Clone)] #[derive(Clone, Default)]
pub struct SwitchLockManager { pub struct SwitchLockManager {
locks: PerAppLocks, locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
}
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 { impl SwitchLockManager {
+1 -5
View File
@@ -1,5 +1,4 @@
use crate::config::{atomic_write, write_json_file}; use crate::config::{atomic_write, write_json_file};
use crate::database::NewProviderAggregate;
use crate::error::AppError; use crate::error::AppError;
use crate::opencode_config::get_opencode_dir; use crate::opencode_config::get_opencode_dir;
use crate::provider::Provider; use crate::provider::Provider;
@@ -289,10 +288,7 @@ impl OmoService {
in_failover_queue: false, in_failover_queue: false,
}; };
state.db.create_provider(NewProviderAggregate::from_input( state.db.save_provider("opencode", &provider)?;
"opencode",
crate::services::provider::provider_to_mutation_input(provider.clone()),
)?)?;
state state
.db .db
.set_omo_provider_current("opencode", &provider.id, v.category)?; .set_omo_provider_current("opencode", &provider.id, v.category)?;
+15 -9
View File
@@ -5,7 +5,6 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::database::{NewEndpoint, ProviderKey};
use crate::error::AppError; use crate::error::AppError;
use crate::settings::CustomEndpoint; use crate::settings::CustomEndpoint;
use crate::store::AppState; use crate::store::AppState;
@@ -48,10 +47,9 @@ pub fn add_custom_endpoint(
)); ));
} }
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
state state
.db .db
.add_provider_endpoint(&key, NewEndpoint::now(normalized)?)?; .add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
Ok(()) Ok(())
} }
@@ -63,8 +61,9 @@ pub fn remove_custom_endpoint(
url: String, url: String,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string(); let normalized = url.trim().trim_end_matches('/').to_string();
let key = ProviderKey::new(app_type.as_str(), provider_id)?; state
state.db.remove_provider_endpoint(&key, &normalized)?; .db
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
Ok(()) Ok(())
} }
@@ -77,10 +76,17 @@ pub fn update_endpoint_last_used(
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string(); let normalized = url.trim().trim_end_matches('/').to_string();
let key = ProviderKey::new(app_type.as_str(), provider_id)?; // Get provider, update last_used, save back
state let mut providers = state.db.get_all_providers(app_type.as_str())?;
.db if let Some(provider) = providers.get_mut(provider_id) {
.touch_provider_endpoint(&key, &normalized, now_millis()) 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(())
} }
/// Get current timestamp in milliseconds /// Get current timestamp in milliseconds
+11 -70
View File
@@ -19,10 +19,7 @@ use crate::store::AppState;
use super::gemini_auth::{ use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType, detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
}; };
use super::{ use super::normalize_claude_models_in_value;
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 /// 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. /// effective budget (openai/codex#31860), far below the 1.05M API spec.
@@ -1282,12 +1279,6 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode // Sync providers based on mode
for app_type in AppType::all() { for app_type in AppType::all() {
if app_type.is_additive_mode() { 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 // Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?; sync_all_providers_to_live(state, &app_type)?;
} else { } else {
@@ -1573,12 +1564,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
.to_string(), .to_string(),
); );
reconcile_provider_record_with_precondition( state.db.save_provider(app_type.as_str(), &provider)?;
&state.db,
app_type.as_str(),
provider_to_mutation_input(provider.clone()),
ReconcilePrecondition::ExpectAbsent,
)?;
state state
.db .db
.set_current_provider(app_type.as_str(), &provider.id)?; .set_current_provider(app_type.as_str(), &provider.id)?;
@@ -1746,25 +1732,15 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
}; };
if existing_ids.contains(&id) { if existing_ids.contains(&id) {
match state.db.get_provider_aggregate("opencode", &id) { match state.db.get_provider_by_id(&id, "opencode") {
Ok(Some(existing)) => { Ok(Some(existing)) => {
let existing = existing.provider;
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone()); let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
if existing.settings_config != settings_config || existing.name != display_name if existing.settings_config != settings_config || existing.name != display_name
{ {
let fingerprint = provider_row_fingerprint(&existing);
let mut provider = existing; let mut provider = existing;
provider.name = display_name; provider.name = display_name;
provider.settings_config = settings_config; provider.settings_config = settings_config;
if let Some(meta) = provider.meta.as_mut() { if let Err(e) = state.db.save_provider("opencode", &provider) {
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!( log::warn!(
"Failed to update OpenCode provider '{id}' from live config: {e}" "Failed to update OpenCode provider '{id}' from live config: {e}"
); );
@@ -1791,12 +1767,7 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
}); });
// Save to database // Save to database
if let Err(e) = reconcile_provider_record_with_precondition( if let Err(e) = state.db.save_provider("opencode", &provider) {
&state.db,
"opencode",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectAbsent,
) {
log::warn!("Failed to import OpenCode provider '{id}': {e}"); log::warn!("Failed to import OpenCode provider '{id}': {e}");
continue; continue;
} }
@@ -1846,22 +1817,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
}; };
if existing_ids.contains(&id) { if existing_ids.contains(&id) {
match state.db.get_provider_aggregate("openclaw", &id) { match state.db.get_provider_by_id(&id, "openclaw") {
Ok(Some(existing)) => { Ok(Some(existing)) => {
let existing = existing.provider;
if existing.settings_config != settings_config { if existing.settings_config != settings_config {
let fingerprint = provider_row_fingerprint(&existing);
let mut provider = existing; let mut provider = existing;
provider.settings_config = settings_config; provider.settings_config = settings_config;
if let Some(meta) = provider.meta.as_mut() { if let Err(e) = state.db.save_provider("openclaw", &provider) {
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!( log::warn!(
"Failed to update OpenClaw provider '{id}' from live config: {e}" "Failed to update OpenClaw provider '{id}' from live config: {e}"
); );
@@ -1894,12 +1855,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
}); });
// Save to database // Save to database
if let Err(e) = reconcile_provider_record_with_precondition( if let Err(e) = state.db.save_provider("openclaw", &provider) {
&state.db,
"openclaw",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectAbsent,
) {
log::warn!("Failed to import OpenClaw provider '{id}': {e}"); log::warn!("Failed to import OpenClaw provider '{id}': {e}");
continue; continue;
} }
@@ -1936,22 +1892,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
} }
if existing_ids.contains(&name) { if existing_ids.contains(&name) {
match state.db.get_provider_aggregate("hermes", &name) { match state.db.get_provider_by_id(&name, "hermes") {
Ok(Some(existing)) => { Ok(Some(existing)) => {
let existing = existing.provider;
if existing.settings_config != config { if existing.settings_config != config {
let fingerprint = provider_row_fingerprint(&existing);
let mut provider = existing; let mut provider = existing;
provider.settings_config = config; provider.settings_config = config;
if let Some(meta) = provider.meta.as_mut() { if let Err(e) = state.db.save_provider("hermes", &provider) {
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!( log::warn!(
"Failed to update Hermes provider '{name}' from live config: {e}" "Failed to update Hermes provider '{name}' from live config: {e}"
); );
@@ -1977,12 +1923,7 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
}); });
// Save to database // Save to database
if let Err(e) = reconcile_provider_record_with_precondition( if let Err(e) = state.db.save_provider("hermes", &provider) {
&state.db,
"hermes",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectAbsent,
) {
log::warn!("Failed to import Hermes provider '{name}': {e}"); log::warn!("Failed to import Hermes provider '{name}': {e}");
continue; continue;
} }
File diff suppressed because it is too large Load Diff
+221 -294
View File
@@ -10,9 +10,7 @@ use crate::proxy::server::ProxyServer;
use crate::proxy::switch_lock::SwitchLockManager; use crate::proxy::switch_lock::SwitchLockManager;
use crate::proxy::types::*; use crate::proxy::types::*;
use crate::services::provider::{ use crate::services::provider::{
build_effective_settings_with_common_config, provider_row_fingerprint, build_effective_settings_with_common_config, write_live_with_common_config,
provider_to_mutation_input, reconcile_provider_record_with_precondition,
write_live_with_common_config, ReconcilePrecondition,
}; };
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::str::FromStr; use std::str::FromStr;
@@ -982,27 +980,6 @@ impl ProxyService {
.await .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( async fn sync_live_config_to_provider(
&self, &self,
app_type: &AppType, app_type: &AppType,
@@ -1015,89 +992,91 @@ impl ProxyService {
.map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?; .map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?;
if let Some(provider_id) = provider_id { if let Some(provider_id) = provider_id {
let Some(mut provider) = self if let Ok(Some(mut provider)) =
.db self.db.get_provider_by_id(&provider_id, "claude")
.get_provider_by_id(&provider_id, "claude") {
.map_err(|error| { if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) {
format!("读取 Claude 供应商 '{provider_id}' 失败: {error}") let token_pair = [
})? "ANTHROPIC_AUTH_TOKEN",
else { "ANTHROPIC_API_KEY",
return Err(format!("Claude 当前供应商不存在: {provider_id}")); "OPENROUTER_API_KEY",
}; "OPENAI_API_KEY",
let observed_fingerprint = provider_row_fingerprint(&provider); ]
if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) { .into_iter()
let token_pair = [ .find_map(|key| {
"ANTHROPIC_AUTH_TOKEN", env.get(key)
"ANTHROPIC_API_KEY", .and_then(|v| v.as_str())
"OPENROUTER_API_KEY", .map(|s| (key, s.trim()))
"OPENAI_API_KEY", })
] .filter(|(_, token)| {
.into_iter() !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER
.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 { if let Some((token_key, token)) = token_pair {
let env_obj = provider let env_obj = provider
.settings_config .settings_config
.get_mut("env") .get_mut("env")
.and_then(|v| v.as_object_mut()); .and_then(|v| v.as_object_mut());
match env_obj { match env_obj {
Some(obj) => { Some(obj) => {
if token_key == "ANTHROPIC_AUTH_TOKEN" if token_key == "ANTHROPIC_AUTH_TOKEN"
|| token_key == "ANTHROPIC_API_KEY" || token_key == "ANTHROPIC_API_KEY"
{ {
let mut updated = false; let mut updated = false;
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") { if obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
obj.insert( obj.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(), "ANTHROPIC_AUTH_TOKEN".to_string(),
json!(token), json!(token),
); );
updated = true; updated = true;
} }
if obj.contains_key("ANTHROPIC_API_KEY") { if obj.contains_key("ANTHROPIC_API_KEY") {
obj.insert( obj.insert(
"ANTHROPIC_API_KEY".to_string(), "ANTHROPIC_API_KEY".to_string(),
json!(token), json!(token),
); );
updated = true; updated = true;
} }
if !updated { if !updated {
obj.insert(token_key.to_string(), json!(token));
}
} else {
obj.insert(token_key.to_string(), json!(token)); 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() { if let Some(root) = provider.settings_config.as_object_mut()
root.insert("env".to_string(), json!({ token_key: token })); {
} else { root.insert(
log::warn!( "env".to_string(),
json!({ token_key: token }),
);
} else {
log::warn!(
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})" "Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
); );
}
} }
} }
}
self.persist_synced_live_token( if let Err(e) = self.db.update_provider_settings_config(
"claude", "claude",
&provider_id, &provider_id,
observed_fingerprint, &provider.settings_config,
provider, ) {
)?; log::warn!("同步 Claude Token 到数据库失败: {e}");
log::info!("已同步 Claude Token 到数据库 (provider: {provider_id})"); } else {
log::info!(
"已同步 Claude Token 到数据库 (provider: {provider_id})"
);
}
}
} }
} }
} }
@@ -1108,56 +1087,55 @@ impl ProxyService {
.map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?; .map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?;
if let Some(provider_id) = provider_id { if let Some(provider_id) = provider_id {
let Some(mut provider) = self if let Ok(Some(mut provider)) =
.db self.db.get_provider_by_id(&provider_id, "codex")
.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)
{ {
if let Some(auth_obj) = provider // The built-in official row is a routing capability, not
.settings_config // a credential store. Its auth must remain empty even
.get_mut("auth") // when the live Codex login uses OPENAI_API_KEY mode.
.and_then(|v| v.as_object_mut()) 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)
{ {
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token)); if let Some(auth_obj) = provider
} else { .settings_config
if provider.settings_config.is_null() { .get_mut("auth")
provider.settings_config = json!({}); .and_then(|v| v.as_object_mut())
} {
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
if let Some(root) = provider.settings_config.as_object_mut() {
root.insert("auth".to_string(), json!({ "OPENAI_API_KEY": token }));
} else { } else {
log::warn!( 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!(
"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})" "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})");
} }
} }
} }
@@ -1167,50 +1145,51 @@ impl ProxyService {
.map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?; .map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?;
if let Some(provider_id) = provider_id { if let Some(provider_id) = provider_id {
let Some(mut provider) = self if let Ok(Some(mut provider)) =
.db self.db.get_provider_by_id(&provider_id, "gemini")
.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(env_obj) = provider if let Some(token) = live_config
.settings_config .get("env")
.get_mut("env") .and_then(|v| v.get("GEMINI_API_KEY"))
.and_then(|v| v.as_object_mut()) .and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
{ {
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token)); if let Some(env_obj) = provider
} else { .settings_config
if provider.settings_config.is_null() { .get_mut("env")
provider.settings_config = json!({}); .and_then(|v| v.as_object_mut())
} {
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
if let Some(root) = provider.settings_config.as_object_mut() {
root.insert("env".to_string(), json!({ "GEMINI_API_KEY": token }));
} else { } else {
log::warn!( 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!(
"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})" "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})");
} }
} }
} }
@@ -1220,41 +1199,38 @@ impl ProxyService {
.map_err(|e| format!("获取 Grok Build 当前供应商失败: {e}"))?; .map_err(|e| format!("获取 Grok Build 当前供应商失败: {e}"))?;
if let Some(provider_id) = provider_id { if let Some(provider_id) = provider_id {
let Some(mut provider) = self if let Ok(Some(mut provider)) =
.db self.db.get_provider_by_id(&provider_id, "grokbuild")
.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)
{ {
if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER { let live_config_toml = live_config
if let Some(provider_config) = provider .get("config")
.settings_config .and_then(Value::as_str)
.get("config") .unwrap_or_default();
.and_then(Value::as_str) if let Some(token) =
{ crate::grok_config::extract_inline_api_key(live_config_toml)
let updated = {
crate::grok_config::update_api_key(provider_config, &token) 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,
)
.map_err(|e| { .map_err(|e| {
format!("更新 Grok Build API Key 失败: {e}") format!("同步 Grok Build Token 到数据库失败: {e}")
})?; })?;
provider.settings_config["config"] = json!(updated); }
self.persist_synced_live_token(
"grokbuild",
&provider_id,
observed_fingerprint,
provider,
)?;
} }
} }
} }
@@ -3837,7 +3813,7 @@ mod tests {
}), }),
None, None,
); );
db.reconcile_provider_fixture("claude", &provider) db.save_provider("claude", &provider)
.expect("save provider"); .expect("save provider");
db.set_current_provider("claude", "p1") db.set_current_provider("claude", "p1")
.expect("set db current provider"); .expect("set db current provider");
@@ -4023,7 +3999,7 @@ wire_api = "responses"
None, None,
); );
provider.category = Some("cn_official".to_string()); provider.category = Some("cn_official".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider)
.expect("save DeepSeek provider"); .expect("save DeepSeek provider");
db.set_current_provider("codex", "deepseek") db.set_current_provider("codex", "deepseek")
.expect("set current provider"); .expect("set current provider");
@@ -4109,7 +4085,7 @@ wire_api = "responses"
None, None,
); );
provider.category = Some("official".to_string()); provider.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider"); .expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek") db.set_current_provider("codex", "deepseek")
.expect("set current provider"); .expect("set current provider");
@@ -4170,7 +4146,7 @@ wire_api = "responses"
None, None,
); );
official.category = Some("official".to_string()); official.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &official) db.save_provider("codex", &official)
.expect("save official provider"); .expect("save official provider");
let mut third_party = Provider::with_id( let mut third_party = Provider::with_id(
@@ -4189,7 +4165,7 @@ wire_api = "responses"
None, None,
); );
third_party.category = Some("custom".to_string()); third_party.category = Some("custom".to_string());
db.reconcile_provider_fixture("codex", &third_party) db.save_provider("codex", &third_party)
.expect("save third-party provider"); .expect("save third-party provider");
db.set_current_provider("codex", "codex-official") db.set_current_provider("codex", "codex-official")
.expect("set current provider"); .expect("set current provider");
@@ -4338,8 +4314,7 @@ wire_api = "responses"
None, None,
); );
official.category = Some("official".to_string()); official.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &official) db.save_provider("codex", &official).expect("save official");
.expect("save official");
db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID) db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID)
.expect("set current"); .expect("set current");
crate::settings::set_current_provider( crate::settings::set_current_provider(
@@ -4417,7 +4392,7 @@ wire_api = "responses"
None, None,
); );
provider.category = Some("official".to_string()); provider.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider"); .expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek") db.set_current_provider("codex", "deepseek")
.expect("set current provider"); .expect("set current provider");
@@ -4497,7 +4472,7 @@ wire_api = "responses"
None, None,
); );
provider.category = Some("official".to_string()); provider.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider"); .expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek") db.set_current_provider("codex", "deepseek")
.expect("set current provider"); .expect("set current provider");
@@ -4609,7 +4584,7 @@ wire_api = "responses"
None, None,
); );
provider.category = Some("official".to_string()); provider.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider"); .expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek") db.set_current_provider("codex", "deepseek")
.expect("set current provider"); .expect("set current provider");
@@ -4727,7 +4702,7 @@ wire_api = "responses"
None, None,
); );
provider.category = Some("official".to_string()); provider.category = Some("official".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider"); .expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek") db.set_current_provider("codex", "deepseek")
.expect("set current provider"); .expect("set current provider");
@@ -4863,7 +4838,7 @@ wire_api = "responses"
None, None,
); );
provider.category = Some("cn_official".to_string()); provider.category = Some("cn_official".to_string());
db.reconcile_provider_fixture("codex", &provider) db.save_provider("codex", &provider)
.expect("save DeepSeek provider"); .expect("save DeepSeek provider");
db.set_current_provider("codex", "deepseek") db.set_current_provider("codex", "deepseek")
.expect("set current provider"); .expect("set current provider");
@@ -5340,7 +5315,7 @@ model = "gpt-5.1-codex"
}), }),
None, None,
); );
db.reconcile_provider_fixture("claude", &provider) db.save_provider("claude", &provider)
.expect("save provider"); .expect("save provider");
db.set_current_provider("claude", "p1") db.set_current_provider("claude", "p1")
.expect("set current provider"); .expect("set current provider");
@@ -5396,7 +5371,7 @@ model = "gpt-5.1-codex"
}), }),
None, None,
); );
db.reconcile_provider_fixture("claude", &provider) db.save_provider("claude", &provider)
.expect("save provider"); .expect("save provider");
db.set_current_provider("claude", "p1") db.set_current_provider("claude", "p1")
.expect("set current provider"); .expect("set current provider");
@@ -5432,54 +5407,6 @@ 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] #[tokio::test]
#[serial] #[serial]
async fn switch_proxy_target_updates_live_backup_when_taken_over() { async fn switch_proxy_target_updates_live_backup_when_taken_over() {
@@ -5509,9 +5436,9 @@ model = "gpt-5.1-codex"
}), }),
None, None,
); );
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("claude", &provider_b) db.save_provider("claude", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("claude", "a") db.set_current_provider("claude", "a")
.expect("set current provider"); .expect("set current provider");
@@ -5584,9 +5511,9 @@ model = "gpt-5.1-codex"
None, None,
); );
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("claude", &provider_b) db.save_provider("claude", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("claude", "a") db.set_current_provider("claude", "a")
.expect("set current provider"); .expect("set current provider");
@@ -5735,11 +5662,11 @@ model = "gpt-5.1-codex"
None, None,
); );
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("claude", &provider_b) db.save_provider("claude", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.reconcile_provider_fixture("claude", &provider_c) db.save_provider("claude", &provider_c)
.expect("save provider c"); .expect("save provider c");
db.set_current_provider("claude", "a") db.set_current_provider("claude", "a")
.expect("set current provider"); .expect("set current provider");
@@ -5822,9 +5749,9 @@ model = "gpt-5.1-codex"
None, None,
); );
db.reconcile_provider_fixture("claude", &provider_a) db.save_provider("claude", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("claude", &provider_b) db.save_provider("claude", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("claude", "a") db.set_current_provider("claude", "a")
.expect("set current provider"); .expect("set current provider");
@@ -6122,9 +6049,9 @@ requires_openai_auth = true
None, None,
); );
db.reconcile_provider_fixture("codex", &provider_a) db.save_provider("codex", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("codex", &provider_b) db.save_provider("codex", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("codex", "a") db.set_current_provider("codex", "a")
.expect("set current provider"); .expect("set current provider");
@@ -6297,9 +6224,9 @@ requires_openai_auth = true
..Default::default() ..Default::default()
}); });
db.reconcile_provider_fixture("codex", &provider_a) db.save_provider("codex", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("codex", &provider_b) db.save_provider("codex", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("codex", "a") db.set_current_provider("codex", "a")
.expect("set current provider"); .expect("set current provider");
@@ -6541,9 +6468,9 @@ requires_openai_auth = true
..Default::default() ..Default::default()
}); });
db.reconcile_provider_fixture("codex", &provider_a) db.save_provider("codex", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("codex", &provider_b) db.save_provider("codex", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("codex", "a") db.set_current_provider("codex", "a")
.expect("set current provider a"); .expect("set current provider a");
@@ -6677,9 +6604,9 @@ requires_openai_auth = true
None, None,
); );
db.reconcile_provider_fixture("codex", &provider_a) db.save_provider("codex", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("codex", &provider_b) db.save_provider("codex", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("codex", "a") db.set_current_provider("codex", "a")
.expect("set current provider a"); .expect("set current provider a");
@@ -6759,9 +6686,9 @@ requires_openai_auth = true
}), }),
None, None,
); );
db.reconcile_provider_fixture("codex", &provider_a) db.save_provider("codex", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("codex", &provider_b) db.save_provider("codex", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("codex", "a") db.set_current_provider("codex", "a")
.expect("set current provider a"); .expect("set current provider a");
@@ -7043,7 +6970,7 @@ requires_openai_auth = true
}), }),
None, None,
); );
db.reconcile_provider_fixture("claude", &provider) db.save_provider("claude", &provider)
.expect("save provider"); .expect("save provider");
db.set_current_provider("claude", "p1") db.set_current_provider("claude", "p1")
.expect("set current provider"); .expect("set current provider");
@@ -7297,9 +7224,9 @@ experimental_bearer_token = "PROXY_MANAGED"
grok_provider_config("https://b.example.com/v1", "b-key"), grok_provider_config("https://b.example.com/v1", "b-key"),
None, None,
); );
db.reconcile_provider_fixture("grokbuild", &provider_a) db.save_provider("grokbuild", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("grokbuild", &provider_b) db.save_provider("grokbuild", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("grokbuild", "grok-a") db.set_current_provider("grokbuild", "grok-a")
.expect("set db current"); .expect("set db current");
@@ -7364,9 +7291,9 @@ experimental_bearer_token = "PROXY_MANAGED"
json!({ "config": "not valid toml = [" }), json!({ "config": "not valid toml = [" }),
None, None,
); );
db.reconcile_provider_fixture("grokbuild", &provider_a) db.save_provider("grokbuild", &provider_a)
.expect("save provider a"); .expect("save provider a");
db.reconcile_provider_fixture("grokbuild", &provider_b) db.save_provider("grokbuild", &provider_b)
.expect("save provider b"); .expect("save provider b");
db.set_current_provider("grokbuild", "grok-a") db.set_current_provider("grokbuild", "grok-a")
.expect("set db current"); .expect("set db current");
+87 -10
View File
@@ -2374,31 +2374,38 @@ impl SkillService {
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。 /// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
/// ///
/// 兼容三种情况 /// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序
/// 1. `skills/foo` 这类直接相对路径 /// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录; /// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录 /// 3. 兜底:仓库根本身含 `SKILL.md`
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> { fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
let source_rel = Self::sanitize_skill_source_path(raw_directory)?; 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); let direct = root.join(&source_rel);
if direct.is_dir() { if direct.is_dir() && direct.join("SKILL.md").is_file() {
return Some(direct); return Some(direct);
} }
let target_name = source_rel.file_name()?.to_string_lossy().to_string(); // 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) { if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
log::info!( log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}", "Skill directory '{}' not found at direct path, using fallback: {}",
target_name, install_name,
found.display() found.display()
); );
return Some(found); return Some(found);
} }
if root.is_dir() && root.join("SKILL.md").exists() { // 3. 兜底:仓库根本身是 skill
if root.join("SKILL.md").is_file() {
log::info!( log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root", "Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
target_name, install_name,
); );
return Some(root.to_path_buf()); return Some(root.to_path_buf());
} }
@@ -4452,4 +4459,74 @@ mod tests {
"existing destination skill should be preserved" "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}; use crate::services::skill::{SkillStorageLocation, SyncMethod};
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints /// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CustomEndpoint { pub struct CustomEndpoint {
pub url: String, pub url: String,
pub added_at: Option<i64>, pub added_at: i64,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<i64>, pub last_used: Option<i64>,
} }
+59 -72
View File
@@ -7,14 +7,13 @@ use std::fs;
use serde_json::json; use serde_json::json;
use cc_switch_lib::{ use cc_switch_lib::{
AppType, InstalledSkill, McpServer, McpService, NewProviderAggregate, ProfilePayload, AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService,
ProfileScope, ProfileService, Prompt, PromptService, Provider, ProviderService, SkillApps, Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService,
SkillService,
}; };
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
use support::{create_test_state, ensure_test_home, new_provider_input, reset_test_fs, test_mutex}; use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
fn claude_provider(id: &str, token: &str) -> Provider { fn claude_provider(id: &str, token: &str) -> Provider {
Provider::with_id( Provider::with_id(
@@ -108,41 +107,34 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
let state = create_test_state().expect("create test state"); let state = create_test_state().expect("create test state");
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ---- // ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
ProviderService::add( state
&state, .db
AppType::Claude, .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
new_provider_input(claude_provider("p1", "key-1")), .expect("save provider p1");
false, state
) .db
.expect("create provider p1"); .save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
ProviderService::add( .expect("save provider p2");
&state,
AppType::Claude,
new_provider_input(claude_provider("p2", "key-2")),
false,
)
.expect("create provider p2");
state state
.db .db
.set_current_provider(AppType::Claude.as_str(), "p1") .set_current_provider(AppType::Claude.as_str(), "p1")
.expect("set current provider p1"); .expect("set current provider p1");
// Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用) // Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用)
for provider in [ state
desktop_provider("d1", "dk-1"), .db
desktop_provider("d2", "dk-2"), .save_provider(
] { AppType::ClaudeDesktop.as_str(),
state &desktop_provider("d1", "dk-1"),
.db )
.create_provider( .expect("save desktop provider d1");
NewProviderAggregate::from_input( state
AppType::ClaudeDesktop.as_str(), .db
new_provider_input(provider), .save_provider(
) AppType::ClaudeDesktop.as_str(),
.expect("build typed desktop create"), &desktop_provider("d2", "dk-2"),
) )
.expect("create desktop provider"); .expect("save desktop provider d2");
}
state state
.db .db
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1") .set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
@@ -295,13 +287,10 @@ fn shared_profile_sides_are_isolated_and_mergeable() {
let state = create_test_state().expect("create test state"); let state = create_test_state().expect("create test state");
// 种子:Claude 侧有当前供应商 + 启用的 MCP // 种子:Claude 侧有当前供应商 + 启用的 MCP
ProviderService::add( state
&state, .db
AppType::Claude, .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
new_provider_input(claude_provider("p1", "key-1")), .expect("save provider p1");
false,
)
.expect("create provider p1");
state state
.db .db
.set_current_provider(AppType::Claude.as_str(), "p1") .set_current_provider(AppType::Claude.as_str(), "p1")
@@ -507,20 +496,14 @@ fn switching_profile_autosaves_previous_profile_state() {
let state = create_test_state().expect("create test state"); let state = create_test_state().expect("create test state");
// ---- 种子:Claude 侧两套供应商 / MCP / Prompt ---- // ---- 种子:Claude 侧两套供应商 / MCP / Prompt ----
ProviderService::add( state
&state, .db
AppType::Claude, .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
new_provider_input(claude_provider("p1", "key-1")), .expect("save provider p1");
false, state
) .db
.expect("create provider p1"); .save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
ProviderService::add( .expect("save provider p2");
&state,
AppType::Claude,
new_provider_input(claude_provider("p2", "key-2")),
false,
)
.expect("create provider p2");
state state
.db .db
.set_current_provider(AppType::Claude.as_str(), "p1") .set_current_provider(AppType::Claude.as_str(), "p1")
@@ -682,13 +665,17 @@ fn profile_switch_auto_disables_takeover_before_apply() {
// ---- 两个 Claude 供应商:custom1 与 custom2 ---- // ---- 两个 Claude 供应商:custom1 与 custom2 ----
let mut custom1 = claude_provider("custom1", "custom-key-1"); let mut custom1 = claude_provider("custom1", "custom-key-1");
custom1.category = Some("custom".to_string()); custom1.category = Some("custom".to_string());
ProviderService::add(&state, AppType::Claude, new_provider_input(custom1), false) state
.expect("create custom1 provider"); .db
.save_provider(AppType::Claude.as_str(), &custom1)
.expect("save custom1 provider");
let mut custom2 = claude_provider("custom2", "custom-key-2"); let mut custom2 = claude_provider("custom2", "custom-key-2");
custom2.category = Some("custom".to_string()); custom2.category = Some("custom".to_string());
ProviderService::add(&state, AppType::Claude, new_provider_input(custom2), false) state
.expect("create custom2 provider"); .db
.save_provider(AppType::Claude.as_str(), &custom2)
.expect("save custom2 provider");
// 初始状态:custom1 + 代理接管 // 初始状态:custom1 + 代理接管
ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1"); ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1");
@@ -770,20 +757,20 @@ fn claude_desktop_profile_scope_is_independent() {
let state = create_test_state().expect("create test state"); let state = create_test_state().expect("create test state");
ProviderService::add( state
&state, .db
AppType::ClaudeDesktop, .save_provider(
new_provider_input(desktop_provider("d1", "dk-1")), AppType::ClaudeDesktop.as_str(),
false, &desktop_provider("d1", "dk-1"),
) )
.expect("create desktop provider d1"); .expect("save desktop provider d1");
ProviderService::add( state
&state, .db
AppType::ClaudeDesktop, .save_provider(
new_provider_input(desktop_provider("d2", "dk-2")), AppType::ClaudeDesktop.as_str(),
false, &desktop_provider("d2", "dk-2"),
) )
.expect("create desktop provider d2"); .expect("save desktop provider d2");
state state
.db .db
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1") .set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
+13 -13
View File
@@ -12,7 +12,7 @@ mod support;
use std::collections::HashMap; use std::collections::HashMap;
use support::{ use support::{
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation, create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, new_provider_input, reset_test_fs, test_mutex, ensure_test_home, reset_test_fs, test_mutex,
}; };
fn settings_path(home: &Path) -> PathBuf { 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"); let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
ProviderService::add( state
&state, .db
AppType::GrokBuild, .save_provider(
new_provider_input(Provider::with_id( AppType::GrokBuild.as_str(),
"relay".to_string(), &Provider::with_id(
"Relay".to_string(), "relay".to_string(),
json!({ "config": next_config }), "Relay".to_string(),
None, json!({ "config": next_config }),
)), None,
false, ),
) )
.expect("create second Grok Build provider"); .expect("save second Grok Build provider");
switch_provider_test_hook(&state, AppType::GrokBuild, "relay") switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
.expect("switch Grok Build provider"); .expect("switch Grok Build provider");
+5 -3
View File
@@ -9,7 +9,7 @@ use cc_switch_lib::{
mod support; mod support;
use support::{ use support::{
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation, create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, new_provider_input, reset_test_fs, test_mutex, ensure_test_home, reset_test_fs, test_mutex,
}; };
fn sanitize_provider_name(name: &str) -> String { fn sanitize_provider_name(name: &str) -> String {
@@ -3084,8 +3084,10 @@ fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_ba
taken_over_live.clone(), taken_over_live.clone(),
None, None,
); );
ProviderService::add(&state, AppType::Claude, new_provider_input(provider), false) state
.expect("create placeholder provider"); .db
.save_provider(AppType::Claude.as_str(), &provider)
.expect("save placeholder provider");
state state
.db .db
.set_current_provider(AppType::Claude.as_str(), "default") .set_current_provider(AppType::Claude.as_str(), "default")
+1 -25
View File
@@ -1,31 +1,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use cc_switch_lib::{ use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
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 目录,避免污染真实用户数据。 /// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path { pub fn ensure_test_home() -> &'static Path {
+55
View File
@@ -0,0 +1,55 @@
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,9 +24,12 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth"; import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard"; import { copyText } from "@/lib/clipboard";
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
interface CodexOAuthSectionProps { interface CodexOAuthSectionProps {
className?: string; className?: string;
/** 是否展示每个账号的订阅额度 */
showAccountQuota?: boolean;
/** 当前选中的 ChatGPT 账号 ID */ /** 当前选中的 ChatGPT 账号 ID */
selectedAccountId?: string | null; selectedAccountId?: string | null;
/** 账号选择回调 */ /** 账号选择回调 */
@@ -45,6 +48,7 @@ interface CodexOAuthSectionProps {
*/ */
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className, className,
showAccountQuota = false,
selectedAccountId, selectedAccountId,
onAccountSelect, onAccountSelect,
fastModeEnabled = false, fastModeEnabled = false,
@@ -178,47 +182,52 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{accounts.map((account) => ( {accounts.map((account) => (
<div <div
key={account.id} key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30" className="space-y-2 p-2 rounded-md border bg-muted/30"
> >
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<User className="h-5 w-5 text-muted-foreground" /> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{account.login}</span> <User className="h-5 w-5 text-muted-foreground" />
{defaultAccountId === account.id && ( <span className="text-sm font-medium">{account.login}</span>
<Badge variant="secondary" className="text-xs"> {defaultAccountId === account.id && (
{t("codexOauth.defaultAccount", "默认")} <Badge variant="secondary" className="text-xs">
</Badge> {t("codexOauth.defaultAccount", "默认")}
)} </Badge>
{selectedAccountId === account.id && ( )}
<Badge variant="outline" className="text-xs"> {selectedAccountId === account.id && (
{t("codexOauth.selected", "已选中")} <Badge variant="outline" className="text-xs">
</Badge> {t("codexOauth.selected", "已选中")}
)} </Badge>
</div> )}
<div className="flex items-center gap-1"> </div>
{defaultAccountId !== account.id && ( <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>
)}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="icon"
className="h-7 px-2 text-xs text-muted-foreground" className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={() => setDefaultAccount(account.id)} onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isSettingDefaultAccount} disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
> >
{t("codexOauth.setAsDefault", "设为默认")} <X className="h-4 w-4" />
</Button> </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> </div>
{showAccountQuota && (
<CodexOauthAccountQuota accountId={account.id} />
)}
</div> </div>
))} ))}
</div> </div>
@@ -1537,16 +1537,8 @@ function ProviderFormFull({
} }
} }
const metaSource = payload.meta ?? initialData?.meta; const baseMeta: ProviderMeta | undefined =
const baseMeta: ProviderMeta | undefined = metaSource payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
? { ...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(新建时从预设获取,编辑时从现有数据获取) // 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType = presetProviderType || initialData?.meta?.providerType; const providerType = presetProviderType || initialData?.meta?.providerType;
+1 -1
View File
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
</div> </div>
</div> </div>
<CodexOAuthSection /> <CodexOAuthSection showAccountQuota />
</section> </section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6"> <section className="rounded-xl border border-border/60 bg-card/60 p-6">
+1 -5
View File
@@ -30,7 +30,6 @@ import {
supportsOfficialProxyTakeover, supportsOfficialProxyTakeover,
} from "@/utils/providerCapabilities"; } from "@/utils/providerCapabilities";
import { isOAuthProviderType } from "@/config/constants"; import { isOAuthProviderType } from "@/config/constants";
import { toProviderUpdateInput } from "@/lib/api/providers";
/** /**
* Hook for managing provider actions (add, update, delete, switch) * Hook for managing provider actions (add, update, delete, switch)
@@ -363,10 +362,7 @@ export function useProviderActions(
}, },
}; };
await providersApi.update( await providersApi.update(updatedProvider, activeApp);
toProviderUpdateInput(updatedProvider),
activeApp,
);
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["providers", activeApp], queryKey: ["providers", activeApp],
}); });
-40
View File
@@ -1,40 +0,0 @@
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();
});
});
+1 -35
View File
@@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { import type {
Provider, Provider,
ProviderMeta,
UniversalProvider, UniversalProvider,
UniversalProvidersMap, UniversalProvidersMap,
} from "@/types"; } from "@/types";
@@ -13,39 +12,6 @@ export interface ProviderSortUpdate {
sortIndex: number; 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 { export interface ProviderSwitchEvent {
appType: AppId; appType: AppId;
providerId: string; providerId: string;
@@ -98,7 +64,7 @@ export const providersApi = {
}, },
async update( async update(
provider: ProviderUpdateInput, provider: Provider,
appId: AppId, appId: AppId,
originalId?: string, originalId?: string,
): Promise<boolean> { ): Promise<boolean> {
+2 -6
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api"; import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
import type { DeleteSessionOptions } from "@/lib/api/sessions"; import type { DeleteSessionOptions } from "@/lib/api/sessions";
import { toProviderUpdateInput, type SwitchResult } from "@/lib/api/providers"; import type { SwitchResult } from "@/lib/api/providers";
import type { Provider, SessionMeta, Settings } from "@/types"; import type { Provider, SessionMeta, Settings } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { generateUUID } from "@/utils/uuid"; import { generateUUID } from "@/utils/uuid";
@@ -168,11 +168,7 @@ export const useUpdateProviderMutation = (appId: AppId) => {
provider: Provider; provider: Provider;
originalId?: string; originalId?: string;
}) => { }) => {
await providersApi.update( await providersApi.update(provider, appId, originalId);
toProviderUpdateInput(provider),
appId,
originalId,
);
return provider; return provider;
}, },
onSuccess: async (provider, variables) => { onSuccess: async (provider, variables) => {
+21 -8
View File
@@ -112,20 +112,18 @@ export interface UseCodexOauthQuotaOptions {
} }
/** /**
* Codex OAuth (ChatGPT Plus/Pro ) hook * Codex OAuth hook ID
* *
* `useSubscriptionQuota` cc-switch OAuth token * cc-switch ChatGPT ID
* Codex CLI ~/.codex/auth.json * Query key `useCodexOauthQuota`
* *
* Query key accountId
* accountId null 使 "default" fallback * accountId null 使 "default" fallback
*/ */
export function useCodexOauthQuota( export function useCodexOauthQuotaByAccountId(
meta: ProviderMeta | undefined, accountId: string | null,
options: UseCodexOauthQuotaOptions = {}, options: UseCodexOauthQuotaOptions = {},
) { ) {
const { enabled = true, autoQuery = false } = options; const { enabled = true, autoQuery = false } = options;
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
const query = useQuery({ const query = useQuery({
queryKey: ["codex_oauth", "quota", accountId ?? "default"], queryKey: ["codex_oauth", "quota", accountId ?? "default"],
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId), queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
@@ -140,6 +138,21 @@ export function useCodexOauthQuota(
return useQuotaKeepLastGood(query, accountId ?? "default"); 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 * xAI OAuth (SuperGrok ) hook
* *
+1 -1
View File
@@ -38,7 +38,7 @@ export interface AppConfig {
// 自定义端点配置 // 自定义端点配置
export interface CustomEndpoint { export interface CustomEndpoint {
url: string; url: string;
addedAt: number | null; addedAt: number;
lastUsed?: number; lastUsed?: number;
} }
@@ -0,0 +1,74 @@
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
@@ -1,35 +0,0 @@
{
"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"]
}
}