mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7d37320f5 | |||
| ac764b4818 |
Generated
+1
@@ -799,6 +799,7 @@ 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",
|
||||||
|
|||||||
@@ -116,3 +116,4 @@ strip = "symbols"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serial_test = "3"
|
serial_test = "3"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
syn = { version = "2", features = ["full", "visit"] }
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ use crate::codex_state_db::codex_state_db_paths;
|
|||||||
use crate::config::{atomic_write, copy_file, get_app_config_dir};
|
use crate::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,
|
||||||
@@ -663,7 +667,8 @@ 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 (_, provider) in providers {
|
for (_, mut 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()
|
||||||
@@ -694,8 +699,21 @@ 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));
|
||||||
db.update_provider_settings_config("codex", &provider.id, &settings)?;
|
let provider_id = provider.id.clone();
|
||||||
migrated_provider_ids.push(provider.id);
|
provider.settings_config = settings;
|
||||||
|
if let Some(meta) = provider.meta.as_mut() {
|
||||||
|
meta.custom_endpoints.clear();
|
||||||
|
}
|
||||||
|
let input = provider_to_mutation_input(provider);
|
||||||
|
reconcile_provider_record_with_precondition(
|
||||||
|
db,
|
||||||
|
"codex",
|
||||||
|
input,
|
||||||
|
ReconcilePrecondition::ExpectPresent {
|
||||||
|
fingerprint: observed_fingerprint,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
migrated_provider_ids.push(provider_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
||||||
@@ -1439,7 +1457,8 @@ base_url = "https://proxy.example/v1"
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
for provider in providers {
|
for provider in providers {
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut official = Provider::with_id(
|
let mut official = Provider::with_id(
|
||||||
@@ -1449,7 +1468,8 @@ base_url = "https://proxy.example/v1"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &official).expect("save official");
|
db.reconcile_provider_fixture("codex", &official)
|
||||||
|
.expect("save official");
|
||||||
|
|
||||||
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2171,9 +2191,10 @@ base_url = "https://proxy.example/v1"
|
|||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
|
|
||||||
db.save_provider("codex", &third_party)
|
db.reconcile_provider_fixture("codex", &third_party)
|
||||||
.expect("save third-party");
|
.expect("save third-party");
|
||||||
db.save_provider("codex", &official).expect("save official");
|
db.reconcile_provider_fixture("codex", &official)
|
||||||
|
.expect("save official");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(ids.contains("rightcode"));
|
assert!(ids.contains("rightcode"));
|
||||||
@@ -2196,7 +2217,8 @@ base_url = "https://proxy.example/v1"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2216,7 +2238,8 @@ base_url = "https://proxy.example/v1"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2244,7 +2267,8 @@ model_provider = "my-private-relay"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2264,7 +2288,8 @@ model_provider = "my-private-relay"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(ids.contains("aihubmix"));
|
assert!(ids.contains("aihubmix"));
|
||||||
@@ -2285,7 +2310,8 @@ model_provider = "my-private-relay"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(ids.contains("ccswitch"));
|
assert!(ids.contains("ccswitch"));
|
||||||
@@ -2317,7 +2343,8 @@ model = "gpt-5.4"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
|
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()]);
|
||||||
@@ -2390,7 +2417,8 @@ base_url = "https://aihubmix.example/v1"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2446,7 +2474,8 @@ base_url = "http://localhost:8080/v1"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert!(outcome.migrated_provider_ids.is_empty());
|
assert!(outcome.migrated_provider_ids.is_empty());
|
||||||
@@ -2495,7 +2524,8 @@ base_url = "https://proxy.example/v1"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert!(outcome.migrated_provider_ids.is_empty());
|
assert!(outcome.migrated_provider_ids.is_empty());
|
||||||
@@ -2552,7 +2582,8 @@ model_provider = "aihubmix"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
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()]);
|
||||||
@@ -2601,7 +2632,8 @@ 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.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2622,7 +2654,8 @@ model_provider = "aihubmix"
|
|||||||
);
|
);
|
||||||
provider.category = Some("custom".to_string());
|
provider.category = Some("custom".to_string());
|
||||||
|
|
||||||
db.save_provider("codex", &provider).expect("save provider");
|
db.reconcile_provider_fixture("codex", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-local-relay"));
|
assert!(!ids.contains("my-local-relay"));
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ 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};
|
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMutationInput};
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||||
};
|
};
|
||||||
@@ -39,7 +40,7 @@ pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<S
|
|||||||
pub fn add_provider(
|
pub fn add_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
provider: Provider,
|
provider: ProviderMutationInput,
|
||||||
#[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())?;
|
||||||
@@ -51,7 +52,7 @@ pub fn add_provider(
|
|||||||
pub fn update_provider(
|
pub fn update_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
provider: Provider,
|
provider: ProviderMutationInput,
|
||||||
#[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())?;
|
||||||
@@ -250,7 +251,13 @@ pub fn import_claude_desktop_providers_from_claude(
|
|||||||
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
|
.create_provider(
|
||||||
|
NewProviderAggregate::from_input(
|
||||||
|
AppType::ClaudeDesktop.as_str(),
|
||||||
|
crate::services::provider::provider_to_mutation_input(desktop_provider),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,640 @@
|
|||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::{ProviderMeta, ProviderMutationInput};
|
||||||
|
use crate::settings::CustomEndpoint;
|
||||||
|
use rusqlite::{params, OptionalExtension, Transaction};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use super::providers::{StoredProviderRow, PROVIDER_SELECT};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProviderKey {
|
||||||
|
app_type: String,
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderKey {
|
||||||
|
pub fn new(app_type: impl Into<String>, id: impl Into<String>) -> Result<Self, AppError> {
|
||||||
|
let app_type = app_type.into();
|
||||||
|
let id = id.into();
|
||||||
|
if app_type.trim().is_empty() || id.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"provider app type and id must be non-empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self { app_type, id })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_type(&self) -> &str {
|
||||||
|
&self.app_type
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProviderRowUpdate {
|
||||||
|
name: String,
|
||||||
|
settings_config: Value,
|
||||||
|
website_url: Option<String>,
|
||||||
|
category: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
meta: ProviderMeta,
|
||||||
|
icon: Option<String>,
|
||||||
|
icon_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRowUpdate {
|
||||||
|
pub fn from_input(input: &ProviderMutationInput) -> Result<Self, AppError> {
|
||||||
|
let meta = input.meta.clone().unwrap_or_default();
|
||||||
|
if !meta.custom_endpoints.is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"provider update must not contain customEndpoints; use endpoint operations"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
name: input.name.clone(),
|
||||||
|
settings_config: input.settings_config.clone(),
|
||||||
|
website_url: input.website_url.clone(),
|
||||||
|
category: input.category.clone(),
|
||||||
|
notes: input.notes.clone(),
|
||||||
|
meta,
|
||||||
|
icon: input.icon.clone(),
|
||||||
|
icon_color: input.icon_color.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProviderRowCreate {
|
||||||
|
content: ProviderRowUpdate,
|
||||||
|
created_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewEndpoint {
|
||||||
|
url: String,
|
||||||
|
added_at: Option<i64>,
|
||||||
|
last_used: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewEndpoint {
|
||||||
|
pub fn new(
|
||||||
|
url: impl Into<String>,
|
||||||
|
added_at: Option<i64>,
|
||||||
|
last_used: Option<i64>,
|
||||||
|
) -> Result<Self, AppError> {
|
||||||
|
let url = url.into();
|
||||||
|
if url.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"provider endpoint URL cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
url,
|
||||||
|
added_at,
|
||||||
|
last_used,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now(url: impl Into<String>) -> Result<Self, AppError> {
|
||||||
|
Self::new(url, Some(chrono::Utc::now().timestamp_millis()), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<CustomEndpoint> for NewEndpoint {
|
||||||
|
type Error = AppError;
|
||||||
|
|
||||||
|
fn try_from(endpoint: CustomEndpoint) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(endpoint.url, endpoint.added_at, endpoint.last_used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewProviderAggregate {
|
||||||
|
key: ProviderKey,
|
||||||
|
row: ProviderRowCreate,
|
||||||
|
sort_index: Option<usize>,
|
||||||
|
in_failover_queue: bool,
|
||||||
|
initial_endpoints: Vec<NewEndpoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewProviderAggregate {
|
||||||
|
pub fn from_input(app_type: &str, mut input: ProviderMutationInput) -> Result<Self, AppError> {
|
||||||
|
let endpoints = input
|
||||||
|
.meta
|
||||||
|
.as_mut()
|
||||||
|
.map(|meta| std::mem::take(&mut meta.custom_endpoints))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut seen = HashSet::with_capacity(endpoints.len());
|
||||||
|
let mut initial_endpoints = Vec::with_capacity(endpoints.len());
|
||||||
|
for (key, endpoint) in endpoints {
|
||||||
|
let normalized_key = key.trim().trim_end_matches('/').to_string();
|
||||||
|
let normalized_url = endpoint.url.trim().trim_end_matches('/').to_string();
|
||||||
|
if normalized_key != normalized_url {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"provider endpoint key '{key}' must match endpoint URL '{}'",
|
||||||
|
endpoint.url
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !seen.insert(normalized_url.clone()) {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"duplicate initial provider endpoint '{}'",
|
||||||
|
endpoint.url
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
initial_endpoints.push(NewEndpoint::new(
|
||||||
|
normalized_url,
|
||||||
|
endpoint.added_at,
|
||||||
|
endpoint.last_used,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||||
|
let row = ProviderRowCreate {
|
||||||
|
content: ProviderRowUpdate::from_input(&input)?,
|
||||||
|
created_at: input.created_at,
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
key,
|
||||||
|
row,
|
||||||
|
sort_index: input.sort_index,
|
||||||
|
in_failover_queue: input.in_failover_queue,
|
||||||
|
initial_endpoints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RenameProvider {
|
||||||
|
source: ProviderKey,
|
||||||
|
target_id: String,
|
||||||
|
row: ProviderRowUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenameProvider {
|
||||||
|
pub fn from_input(
|
||||||
|
source: ProviderKey,
|
||||||
|
input: &ProviderMutationInput,
|
||||||
|
) -> Result<Self, AppError> {
|
||||||
|
if !matches!(source.app_type(), "opencode" | "openclaw") {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"provider key changes are restricted to additive OpenCode/OpenClaw providers"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if source.id() == input.id {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"provider rename requires a different target id".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.id.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"provider target id must be non-empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut row = ProviderRowUpdate::from_input(input)?;
|
||||||
|
// A successful key change always remains DB-only. The service owns
|
||||||
|
// the corresponding live-file absence check, while the DAO persists
|
||||||
|
// the durable half of that invariant.
|
||||||
|
row.meta.live_config_managed = Some(false);
|
||||||
|
Ok(Self {
|
||||||
|
source,
|
||||||
|
target_id: input.id.clone(),
|
||||||
|
row,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
|
||||||
|
let settings_config = serde_json::to_string(&row.settings_config).map_err(|error| {
|
||||||
|
AppError::Database(format!("failed to serialize settings_config: {error}"))
|
||||||
|
})?;
|
||||||
|
let meta = serde_json::to_string(&row.meta).map_err(|error| {
|
||||||
|
AppError::Database(format!("failed to serialize provider meta: {error}"))
|
||||||
|
})?;
|
||||||
|
Ok((settings_config, meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_row(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
key: &ProviderKey,
|
||||||
|
row: &ProviderRowUpdate,
|
||||||
|
created_at: Option<i64>,
|
||||||
|
sort_index: Option<usize>,
|
||||||
|
is_current: bool,
|
||||||
|
in_failover_queue: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let (settings_config, meta) = encode_row(row)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO providers (
|
||||||
|
id, app_type, name, settings_config, website_url, category,
|
||||||
|
created_at, sort_index, notes, icon, icon_color, meta,
|
||||||
|
is_current, in_failover_queue
|
||||||
|
) VALUES (
|
||||||
|
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
|
||||||
|
)",
|
||||||
|
params![
|
||||||
|
key.id,
|
||||||
|
key.app_type,
|
||||||
|
row.name,
|
||||||
|
settings_config,
|
||||||
|
row.website_url,
|
||||||
|
row.category,
|
||||||
|
created_at,
|
||||||
|
sort_index,
|
||||||
|
row.notes,
|
||||||
|
row.icon,
|
||||||
|
row.icon_color,
|
||||||
|
meta,
|
||||||
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| match &error {
|
||||||
|
rusqlite::Error::SqliteFailure(code, _)
|
||||||
|
if matches!(
|
||||||
|
code.extended_code,
|
||||||
|
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
||||||
|
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
AppError::Conflict(format!(
|
||||||
|
"provider '{}/{}' already exists",
|
||||||
|
key.app_type, key.id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => AppError::Database(error.to_string()),
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_endpoint(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
key: &ProviderKey,
|
||||||
|
endpoint: &NewEndpoint,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO provider_endpoints
|
||||||
|
(provider_id, app_type, url, added_at, last_used)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
params![
|
||||||
|
key.id,
|
||||||
|
key.app_type,
|
||||||
|
endpoint.url,
|
||||||
|
endpoint.added_at,
|
||||||
|
endpoint.last_used
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact aggregate replacement is sealed inside the DAO parent module. The
|
||||||
|
/// catalog compensation coordinator introduced with the ordered mutation
|
||||||
|
/// pipeline is the only intended caller.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
// The certification contract keeps immutable creation time separate from the
|
||||||
|
// mutable row DTO and calls this sealed helper directly with the full snapshot.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn restore_provider_aggregate_on_tx(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
key: &ProviderKey,
|
||||||
|
row: &ProviderRowUpdate,
|
||||||
|
created_at: Option<i64>,
|
||||||
|
sort_index: Option<usize>,
|
||||||
|
is_current: bool,
|
||||||
|
in_failover_queue: bool,
|
||||||
|
endpoints: &[NewEndpoint],
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let updated = update_row(tx, key, row)?;
|
||||||
|
if updated == 0 {
|
||||||
|
insert_row(
|
||||||
|
tx,
|
||||||
|
key,
|
||||||
|
row,
|
||||||
|
created_at,
|
||||||
|
sort_index,
|
||||||
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
// Exact compensation is the only path allowed to restore immutable
|
||||||
|
// creation time after a prior aggregate mutation.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||||
|
params![created_at, key.id, key.app_type],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
params![key.id, key.app_type],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
for endpoint in endpoints {
|
||||||
|
insert_endpoint(tx, key, endpoint)?;
|
||||||
|
}
|
||||||
|
// State and order are maintained by their dedicated authorities. Exact
|
||||||
|
// compensation may restore their captured values without exposing them in
|
||||||
|
// ProviderRowUpdate.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers
|
||||||
|
SET sort_index = ?1, is_current = ?2, in_failover_queue = ?3
|
||||||
|
WHERE id = ?4 AND app_type = ?5",
|
||||||
|
params![
|
||||||
|
sort_index,
|
||||||
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
|
key.id,
|
||||||
|
key.app_type
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_row(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
key: &ProviderKey,
|
||||||
|
row: &ProviderRowUpdate,
|
||||||
|
) -> Result<usize, AppError> {
|
||||||
|
let (settings_config, meta) = encode_row(row)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers SET
|
||||||
|
name = ?1,
|
||||||
|
settings_config = ?2,
|
||||||
|
website_url = ?3,
|
||||||
|
category = ?4,
|
||||||
|
notes = ?5,
|
||||||
|
icon = ?6,
|
||||||
|
icon_color = ?7,
|
||||||
|
meta = ?8
|
||||||
|
WHERE id = ?9 AND app_type = ?10",
|
||||||
|
params![
|
||||||
|
row.name,
|
||||||
|
settings_config,
|
||||||
|
row.website_url,
|
||||||
|
row.category,
|
||||||
|
row.notes,
|
||||||
|
row.icon,
|
||||||
|
row.icon_color,
|
||||||
|
meta,
|
||||||
|
key.id,
|
||||||
|
key.app_type,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn create_provider(&self, input: NewProviderAggregate) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
insert_row(
|
||||||
|
&tx,
|
||||||
|
&input.key,
|
||||||
|
&input.row.content,
|
||||||
|
input.row.created_at,
|
||||||
|
input.sort_index,
|
||||||
|
false,
|
||||||
|
input.in_failover_queue,
|
||||||
|
)?;
|
||||||
|
for endpoint in &input.initial_endpoints {
|
||||||
|
insert_endpoint(&tx, &input.key, endpoint)?;
|
||||||
|
}
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_provider(
|
||||||
|
&self,
|
||||||
|
key: &ProviderKey,
|
||||||
|
row: &ProviderRowUpdate,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
if update_row(&tx, key, row)? != 1 {
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"provider '{}/{}'",
|
||||||
|
key.app_type, key.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_provider_if_content_fingerprint(
|
||||||
|
&self,
|
||||||
|
key: &ProviderKey,
|
||||||
|
expected_fingerprint: &str,
|
||||||
|
row: &ProviderRowUpdate,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
let current = tx
|
||||||
|
.query_row(
|
||||||
|
&format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"),
|
||||||
|
params![key.id, key.app_type],
|
||||||
|
StoredProviderRow::from_row,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))?
|
||||||
|
.decode(key.app_type())?;
|
||||||
|
if current.row_content_fingerprint() != expected_fingerprint {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"provider '{}/{}' changed since it was read",
|
||||||
|
key.app_type, key.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if update_row(&tx, key, row)? != 1 {
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"provider '{}/{}'",
|
||||||
|
key.app_type, key.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rename_db_only_additive_provider(
|
||||||
|
&self,
|
||||||
|
input: RenameProvider,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
let source_state = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT sort_index, is_current, in_failover_queue, category, created_at, meta
|
||||||
|
FROM providers
|
||||||
|
WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![input.source.id, input.source.app_type],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, Option<usize>>(0)?,
|
||||||
|
row.get::<_, bool>(1)?,
|
||||||
|
row.get::<_, bool>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, Option<i64>>(4)?,
|
||||||
|
row.get::<_, String>(5)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::NotFound(format!(
|
||||||
|
"provider '{}/{}'",
|
||||||
|
input.source.app_type, input.source.id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if matches!(source_state.3.as_deref(), Some("omo" | "omo-slim")) {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"OMO/OMO Slim providers cannot be renamed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let source_meta: ProviderMeta = if source_state.5.trim().is_empty() {
|
||||||
|
ProviderMeta::default()
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(&source_state.5).map_err(|error| {
|
||||||
|
AppError::Database(format!(
|
||||||
|
"invalid meta for provider '{}/{}': {error}",
|
||||||
|
input.source.app_type, input.source.id
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
if source_meta.live_config_managed == Some(true) {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"provider '{}/{}' became live-managed before rename",
|
||||||
|
input.source.app_type, input.source.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let target = ProviderKey::new(&input.source.app_type, &input.target_id)?;
|
||||||
|
insert_row(
|
||||||
|
&tx,
|
||||||
|
&target,
|
||||||
|
&input.row,
|
||||||
|
source_state.4,
|
||||||
|
source_state.0,
|
||||||
|
source_state.1,
|
||||||
|
source_state.2,
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO provider_endpoints
|
||||||
|
(provider_id, app_type, url, added_at, last_used)
|
||||||
|
SELECT ?1, app_type, url, added_at, last_used
|
||||||
|
FROM provider_endpoints
|
||||||
|
WHERE provider_id = ?2 AND app_type = ?3
|
||||||
|
ORDER BY id",
|
||||||
|
params![target.id, input.source.id, input.source.app_type],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
if tx
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![input.source.id, input.source.app_type],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?
|
||||||
|
!= 1
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"provider '{}/{}'",
|
||||||
|
input.source.app_type, input.source.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_provider_endpoint(
|
||||||
|
&self,
|
||||||
|
key: &ProviderKey,
|
||||||
|
endpoint: NewEndpoint,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||||
|
insert_endpoint(&tx, key, &endpoint)?;
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_provider_endpoint(&self, key: &ProviderKey, url: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
if conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM provider_endpoints
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
||||||
|
params![key.id, key.app_type, url],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?
|
||||||
|
!= 1
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"provider endpoint '{}/{}/{}'",
|
||||||
|
key.app_type, key.id, url
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touch_provider_endpoint(
|
||||||
|
&self,
|
||||||
|
key: &ProviderKey,
|
||||||
|
url: &str,
|
||||||
|
at: i64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
if conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE provider_endpoints
|
||||||
|
SET last_used = ?1
|
||||||
|
WHERE provider_id = ?2 AND app_type = ?3 AND url = ?4",
|
||||||
|
params![at, key.id, key.app_type, url],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?
|
||||||
|
!= 1
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"provider endpoint '{}/{}/{}'",
|
||||||
|
key.app_type, key.id, url
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_provider_sort_index(
|
||||||
|
&self,
|
||||||
|
key: &ProviderKey,
|
||||||
|
sort_index: usize,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
if conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||||
|
params![sort_index, key.id, key.app_type],
|
||||||
|
)
|
||||||
|
.map_err(|error| AppError::Database(error.to_string()))?
|
||||||
|
!= 1
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"provider '{}/{}'",
|
||||||
|
key.app_type, key.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,9 @@ mod schema;
|
|||||||
mod tests;
|
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,
|
||||||
@@ -53,7 +56,7 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 16;
|
pub(crate) const SCHEMA_VERSION: i32 = 17;
|
||||||
|
|
||||||
/// 安全地序列化 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> {
|
||||||
@@ -197,6 +200,11 @@ 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)
|
||||||
@@ -293,3 +301,39 @@ 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)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ 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,
|
||||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
last_used INTEGER,
|
||||||
|
FOREIGN KEY (provider_id, app_type)
|
||||||
|
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
||||||
|
UNIQUE (provider_id, app_type, url)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -97,6 +100,7 @@ impl Database {
|
|||||||
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
enabled_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
|
||||||
@@ -105,6 +109,36 @@ 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 (
|
||||||
@@ -511,6 +545,13 @@ 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}"
|
||||||
@@ -1523,6 +1564,112 @@ 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 返回的模型名称标准化后一致
|
||||||
@@ -2237,7 +2384,6 @@ 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"),
|
||||||
(
|
(
|
||||||
@@ -3223,7 +3369,7 @@ mod tests {
|
|||||||
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||||
|
|
||||||
assert_eq!(Database::get_user_version(&conn)?, 16);
|
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||||
let counts: (i64, i64, i64, i64) = conn.query_row(
|
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'),
|
||||||
@@ -3236,4 +3382,67 @@ 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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(test)]
|
||||||
|
|
||||||
//! 数据库模块测试
|
//! 数据库模块测试
|
||||||
//!
|
//!
|
||||||
//! 包含 Schema 迁移和基本功能的测试。
|
//! 包含 Schema 迁移和基本功能的测试。
|
||||||
|
|||||||
@@ -109,27 +109,35 @@ pub fn import_provider_from_deeplink(
|
|||||||
|
|
||||||
let provider_id = provider.id.clone();
|
let provider_id = provider.id.clone();
|
||||||
|
|
||||||
// Use ProviderService to add the provider
|
// All endpoints supplied by one import request belong to the same create
|
||||||
ProviderService::add(state, app_type.clone(), provider, true)?;
|
// intent. Put the non-primary endpoints into the initial aggregate so the
|
||||||
|
// provider row and its complete endpoint set commit atomically.
|
||||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
let initial_endpoints = &mut provider
|
||||||
for ep in all_endpoints.iter().skip(1) {
|
.meta
|
||||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
.get_or_insert_with(ProviderMeta::default)
|
||||||
|
.custom_endpoints;
|
||||||
|
for endpoint in all_endpoints.iter().skip(1) {
|
||||||
|
let normalized = endpoint.trim().trim_end_matches('/').to_string();
|
||||||
if !normalized.is_empty() {
|
if !normalized.is_empty() {
|
||||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
initial_endpoints.insert(
|
||||||
state,
|
|
||||||
app_type.clone(),
|
|
||||||
&provider_id,
|
|
||||||
normalized.clone(),
|
normalized.clone(),
|
||||||
) {
|
crate::settings::CustomEndpoint {
|
||||||
log::warn!(
|
url: normalized,
|
||||||
"Failed to add custom endpoint '{}': {e}",
|
added_at: Some(timestamp),
|
||||||
crate::url_for_log(&normalized)
|
last_used: None,
|
||||||
);
|
},
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProviderService owns the strict aggregate create.
|
||||||
|
ProviderService::add(
|
||||||
|
state,
|
||||||
|
app_type.clone(),
|
||||||
|
crate::services::provider::provider_to_mutation_input(provider),
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
|
||||||
// If enabled=true, set as current provider
|
// If 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,9 +1,11 @@
|
|||||||
|
#![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::parse_and_merge_config;
|
use super::provider::{import_provider_from_deeplink, parse_and_merge_config};
|
||||||
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
||||||
use super::DeepLinkImportRequest;
|
use super::DeepLinkImportRequest;
|
||||||
use crate::AppType;
|
use crate::AppType;
|
||||||
@@ -952,6 +954,39 @@ 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
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ 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,
|
||||||
|
|||||||
+11
-4
@@ -45,7 +45,10 @@ 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::{Database, Profile};
|
pub use database::{
|
||||||
|
Database, NewEndpoint, NewProviderAggregate, Profile, ProviderKey, ProviderRowUpdate,
|
||||||
|
RenameProvider,
|
||||||
|
};
|
||||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
pub use 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;
|
||||||
@@ -57,7 +60,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, ProviderMeta};
|
pub use provider::{Provider, ProviderAggregate, ProviderMeta, ProviderMutationInput};
|
||||||
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,
|
||||||
@@ -1988,6 +1991,7 @@ 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,
|
||||||
@@ -2001,11 +2005,14 @@ 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 let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
if legacy_migration_succeeded {
|
||||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||||
|
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,84 @@ pub struct Provider {
|
|||||||
pub in_failover_queue: bool,
|
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(
|
||||||
@@ -67,6 +145,72 @@ impl Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn row_content_fingerprint(&self) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
fn hash_canonical(value: &serde_json::Value, hasher: &mut Sha256) {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Null => hasher.update(b"n"),
|
||||||
|
serde_json::Value::Bool(value) => {
|
||||||
|
hasher.update(b"b");
|
||||||
|
hasher.update([*value as u8]);
|
||||||
|
}
|
||||||
|
serde_json::Value::Number(value) => {
|
||||||
|
let text = value.to_string();
|
||||||
|
hasher.update(b"#");
|
||||||
|
hasher.update((text.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(text.as_bytes());
|
||||||
|
}
|
||||||
|
serde_json::Value::String(value) => {
|
||||||
|
hasher.update(b"s");
|
||||||
|
hasher.update((value.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(items) => {
|
||||||
|
hasher.update(b"[");
|
||||||
|
hasher.update((items.len() as u64).to_le_bytes());
|
||||||
|
for item in items {
|
||||||
|
hash_canonical(item, hasher);
|
||||||
|
}
|
||||||
|
hasher.update(b"]");
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
hasher.update(b"{");
|
||||||
|
hasher.update((map.len() as u64).to_le_bytes());
|
||||||
|
let mut keys: Vec<&String> = map.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
for key in keys {
|
||||||
|
hasher.update((key.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(key.as_bytes());
|
||||||
|
hash_canonical(&map[key.as_str()], hasher);
|
||||||
|
}
|
||||||
|
hasher.update(b"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut meta = serde_json::to_value(&self.meta).unwrap_or(serde_json::Value::Null);
|
||||||
|
if let serde_json::Value::Object(map) = &mut meta {
|
||||||
|
map.remove("custom_endpoints");
|
||||||
|
map.remove("customEndpoints");
|
||||||
|
}
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for part in [
|
||||||
|
serde_json::Value::String(self.name.clone()),
|
||||||
|
self.settings_config.clone(),
|
||||||
|
serde_json::to_value(&self.website_url).unwrap_or(serde_json::Value::Null),
|
||||||
|
serde_json::to_value(&self.category).unwrap_or(serde_json::Value::Null),
|
||||||
|
serde_json::to_value(&self.notes).unwrap_or(serde_json::Value::Null),
|
||||||
|
serde_json::to_value(&self.icon).unwrap_or(serde_json::Value::Null),
|
||||||
|
serde_json::to_value(&self.icon_color).unwrap_or(serde_json::Value::Null),
|
||||||
|
meta,
|
||||||
|
] {
|
||||||
|
hash_canonical(&part, &mut hasher);
|
||||||
|
hasher.update([0u8]);
|
||||||
|
}
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_codex_oauth(&self) -> bool {
|
pub fn is_codex_oauth(&self) -> bool {
|
||||||
self.provider_type() == Some("codex_oauth")
|
self.provider_type() == Some("codex_oauth")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,8 +348,10 @@ 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.save_provider("claude", &provider_a).unwrap();
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
db.save_provider("claude", &provider_b).unwrap();
|
.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();
|
||||||
|
|
||||||
@@ -374,8 +376,10 @@ 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.save_provider("claude", &provider_a).unwrap();
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
db.save_provider("claude", &provider_b).unwrap();
|
.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();
|
||||||
@@ -407,8 +411,10 @@ 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.save_provider("claude", &provider_a).unwrap();
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
db.save_provider("claude", &provider_b).unwrap();
|
.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 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
||||||
@@ -444,8 +450,10 @@ 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.save_provider("claude", &provider_a).unwrap();
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
db.save_provider("claude", &provider_b).unwrap();
|
.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();
|
||||||
@@ -485,7 +493,8 @@ 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.save_provider("claude", &provider_a).unwrap();
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
|
.unwrap();
|
||||||
db.add_to_failover_queue("claude", "a").unwrap();
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
|
||||||
// 启用自动故障转移
|
// 启用自动故障转移
|
||||||
|
|||||||
@@ -4,15 +4,32 @@
|
|||||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, OnceLock};
|
||||||
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, Default)]
|
#[derive(Clone)]
|
||||||
pub struct SwitchLockManager {
|
pub struct SwitchLockManager {
|
||||||
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
locks: PerAppLocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SwitchLockManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Some commands construct a short-lived AppState around the shared
|
||||||
|
// database before running a blocking sync. A per-ProxyService map
|
||||||
|
// would give those paths a different lock and defeat serialization
|
||||||
|
// with provider rename/switch operations in the primary AppState.
|
||||||
|
static LOCKS: OnceLock<PerAppLocks> = OnceLock::new();
|
||||||
|
Self {
|
||||||
|
locks: LOCKS
|
||||||
|
.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
|
||||||
|
.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SwitchLockManager {
|
impl SwitchLockManager {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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;
|
||||||
@@ -288,7 +289,10 @@ impl OmoService {
|
|||||||
in_failover_queue: false,
|
in_failover_queue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.db.save_provider("opencode", &provider)?;
|
state.db.create_provider(NewProviderAggregate::from_input(
|
||||||
|
"opencode",
|
||||||
|
crate::services::provider::provider_to_mutation_input(provider.clone()),
|
||||||
|
)?)?;
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
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;
|
||||||
@@ -47,9 +48,10 @@ pub fn add_custom_endpoint(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
.add_provider_endpoint(&key, NewEndpoint::now(normalized)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +63,8 @@ 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();
|
||||||
state
|
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||||
.db
|
state.db.remove_provider_endpoint(&key, &normalized)?;
|
||||||
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,17 +77,10 @@ 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();
|
||||||
|
|
||||||
// Get provider, update last_used, save back
|
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
||||||
let mut providers = state.db.get_all_providers(app_type.as_str())?;
|
state
|
||||||
if let Some(provider) = providers.get_mut(provider_id) {
|
.db
|
||||||
if let Some(meta) = provider.meta.as_mut() {
|
.touch_provider_endpoint(&key, &normalized, now_millis())
|
||||||
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
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ 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::normalize_claude_models_in_value;
|
use super::{
|
||||||
|
normalize_claude_models_in_value, provider_row_fingerprint, provider_to_mutation_input,
|
||||||
|
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
||||||
|
};
|
||||||
|
|
||||||
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
|
/// 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.
|
||||||
@@ -1279,6 +1282,12 @@ 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 {
|
||||||
@@ -1564,7 +1573,12 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
reconcile_provider_record_with_precondition(
|
||||||
|
&state.db,
|
||||||
|
app_type.as_str(),
|
||||||
|
provider_to_mutation_input(provider.clone()),
|
||||||
|
ReconcilePrecondition::ExpectAbsent,
|
||||||
|
)?;
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||||
@@ -1732,15 +1746,25 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
};
|
};
|
||||||
|
|
||||||
if existing_ids.contains(&id) {
|
if existing_ids.contains(&id) {
|
||||||
match state.db.get_provider_by_id(&id, "opencode") {
|
match state.db.get_provider_aggregate("opencode", &id) {
|
||||||
Ok(Some(existing)) => {
|
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 Err(e) = state.db.save_provider("opencode", &provider) {
|
if let Some(meta) = provider.meta.as_mut() {
|
||||||
|
meta.custom_endpoints.clear();
|
||||||
|
}
|
||||||
|
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||||
|
&state.db,
|
||||||
|
"opencode",
|
||||||
|
provider_to_mutation_input(provider),
|
||||||
|
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||||
|
) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to update OpenCode provider '{id}' from live config: {e}"
|
"Failed to update OpenCode provider '{id}' from live config: {e}"
|
||||||
);
|
);
|
||||||
@@ -1767,7 +1791,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||||
|
&state.db,
|
||||||
|
"opencode",
|
||||||
|
provider_to_mutation_input(provider),
|
||||||
|
ReconcilePrecondition::ExpectAbsent,
|
||||||
|
) {
|
||||||
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1817,12 +1846,22 @@ 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_by_id(&id, "openclaw") {
|
match state.db.get_provider_aggregate("openclaw", &id) {
|
||||||
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 Err(e) = state.db.save_provider("openclaw", &provider) {
|
if let Some(meta) = provider.meta.as_mut() {
|
||||||
|
meta.custom_endpoints.clear();
|
||||||
|
}
|
||||||
|
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||||
|
&state.db,
|
||||||
|
"openclaw",
|
||||||
|
provider_to_mutation_input(provider),
|
||||||
|
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||||
|
) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to update OpenClaw provider '{id}' from live config: {e}"
|
"Failed to update OpenClaw provider '{id}' from live config: {e}"
|
||||||
);
|
);
|
||||||
@@ -1855,7 +1894,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||||
|
&state.db,
|
||||||
|
"openclaw",
|
||||||
|
provider_to_mutation_input(provider),
|
||||||
|
ReconcilePrecondition::ExpectAbsent,
|
||||||
|
) {
|
||||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1892,12 +1936,22 @@ 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_by_id(&name, "hermes") {
|
match state.db.get_provider_aggregate("hermes", &name) {
|
||||||
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 Err(e) = state.db.save_provider("hermes", &provider) {
|
if let Some(meta) = provider.meta.as_mut() {
|
||||||
|
meta.custom_endpoints.clear();
|
||||||
|
}
|
||||||
|
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||||
|
&state.db,
|
||||||
|
"hermes",
|
||||||
|
provider_to_mutation_input(provider),
|
||||||
|
ReconcilePrecondition::ExpectPresent { fingerprint },
|
||||||
|
) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to update Hermes provider '{name}' from live config: {e}"
|
"Failed to update Hermes provider '{name}' from live config: {e}"
|
||||||
);
|
);
|
||||||
@@ -1923,7 +1977,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = state.db.save_provider("hermes", &provider) {
|
if let Err(e) = reconcile_provider_record_with_precondition(
|
||||||
|
&state.db,
|
||||||
|
"hermes",
|
||||||
|
provider_to_mutation_input(provider),
|
||||||
|
ReconcilePrecondition::ExpectAbsent,
|
||||||
|
) {
|
||||||
log::warn!("Failed to import Hermes provider '{name}': {e}");
|
log::warn!("Failed to import Hermes provider '{name}': {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+1083
-141
File diff suppressed because it is too large
Load Diff
+294
-221
@@ -10,7 +10,9 @@ use crate::proxy::server::ProxyServer;
|
|||||||
use crate::proxy::switch_lock::SwitchLockManager;
|
use crate::proxy::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, write_live_with_common_config,
|
build_effective_settings_with_common_config, provider_row_fingerprint,
|
||||||
|
provider_to_mutation_input, reconcile_provider_record_with_precondition,
|
||||||
|
write_live_with_common_config, ReconcilePrecondition,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -980,6 +982,27 @@ 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,
|
||||||
@@ -992,91 +1015,89 @@ 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 {
|
||||||
if let Ok(Some(mut provider)) =
|
let Some(mut provider) = self
|
||||||
self.db.get_provider_by_id(&provider_id, "claude")
|
.db
|
||||||
{
|
.get_provider_by_id(&provider_id, "claude")
|
||||||
if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) {
|
.map_err(|error| {
|
||||||
let token_pair = [
|
format!("读取 Claude 供应商 '{provider_id}' 失败: {error}")
|
||||||
"ANTHROPIC_AUTH_TOKEN",
|
})?
|
||||||
"ANTHROPIC_API_KEY",
|
else {
|
||||||
"OPENROUTER_API_KEY",
|
return Err(format!("Claude 当前供应商不存在: {provider_id}"));
|
||||||
"OPENAI_API_KEY",
|
};
|
||||||
]
|
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||||
.into_iter()
|
if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) {
|
||||||
.find_map(|key| {
|
let token_pair = [
|
||||||
env.get(key)
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
.and_then(|v| v.as_str())
|
"ANTHROPIC_API_KEY",
|
||||||
.map(|s| (key, s.trim()))
|
"OPENROUTER_API_KEY",
|
||||||
})
|
"OPENAI_API_KEY",
|
||||||
.filter(|(_, token)| {
|
]
|
||||||
!token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER
|
.into_iter()
|
||||||
});
|
.find_map(|key| {
|
||||||
|
env.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| (key, s.trim()))
|
||||||
|
})
|
||||||
|
.filter(|(_, token)| {
|
||||||
|
!token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER
|
||||||
|
});
|
||||||
|
|
||||||
if let Some((token_key, token)) = token_pair {
|
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 }));
|
||||||
root.insert(
|
} else {
|
||||||
"env".to_string(),
|
log::warn!(
|
||||||
json!({ token_key: token }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::warn!(
|
|
||||||
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = self.db.update_provider_settings_config(
|
|
||||||
"claude",
|
|
||||||
&provider_id,
|
|
||||||
&provider.settings_config,
|
|
||||||
) {
|
|
||||||
log::warn!("同步 Claude Token 到数据库失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"已同步 Claude Token 到数据库 (provider: {provider_id})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.persist_synced_live_token(
|
||||||
|
"claude",
|
||||||
|
&provider_id,
|
||||||
|
observed_fingerprint,
|
||||||
|
provider,
|
||||||
|
)?;
|
||||||
|
log::info!("已同步 Claude Token 到数据库 (provider: {provider_id})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1087,55 +1108,56 @@ impl ProxyService {
|
|||||||
.map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?;
|
.map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
if let Some(provider_id) = provider_id {
|
if let Some(provider_id) = provider_id {
|
||||||
if let Ok(Some(mut provider)) =
|
let Some(mut provider) = self
|
||||||
self.db.get_provider_by_id(&provider_id, "codex")
|
.db
|
||||||
|
.get_provider_by_id(&provider_id, "codex")
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("读取 Codex 供应商 '{provider_id}' 失败: {error}")
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err(format!("Codex 当前供应商不存在: {provider_id}"));
|
||||||
|
};
|
||||||
|
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||||
|
// The built-in official row is a routing capability, not
|
||||||
|
// a credential store. Its auth must remain empty even
|
||||||
|
// when the live Codex login uses OPENAI_API_KEY mode.
|
||||||
|
if crate::proxy::providers::is_codex_official_provider(&provider) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(token) = live_config
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||||
{
|
{
|
||||||
// The built-in official row is a routing capability, not
|
if let Some(auth_obj) = provider
|
||||||
// a credential store. Its auth must remain empty even
|
.settings_config
|
||||||
// when the live Codex login uses OPENAI_API_KEY mode.
|
.get_mut("auth")
|
||||||
if crate::proxy::providers::is_codex_official_provider(&provider) {
|
.and_then(|v| v.as_object_mut())
|
||||||
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
|
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
||||||
.settings_config
|
} else {
|
||||||
.get_mut("auth")
|
if provider.settings_config.is_null() {
|
||||||
.and_then(|v| v.as_object_mut())
|
provider.settings_config = json!({});
|
||||||
{
|
}
|
||||||
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
|
||||||
} else {
|
|
||||||
if provider.settings_config.is_null() {
|
|
||||||
provider.settings_config = json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||||
root.insert(
|
root.insert("auth".to_string(), json!({ "OPENAI_API_KEY": token }));
|
||||||
"auth".to_string(),
|
} else {
|
||||||
json!({ "OPENAI_API_KEY": token }),
|
log::warn!(
|
||||||
);
|
|
||||||
} 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})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1145,51 +1167,50 @@ 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 {
|
||||||
if let Ok(Some(mut provider)) =
|
let Some(mut provider) = self
|
||||||
self.db.get_provider_by_id(&provider_id, "gemini")
|
.db
|
||||||
|
.get_provider_by_id(&provider_id, "gemini")
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("读取 Gemini 供应商 '{provider_id}' 失败: {error}")
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err(format!("Gemini 当前供应商不存在: {provider_id}"));
|
||||||
|
};
|
||||||
|
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||||
|
if let Some(token) = live_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.get("GEMINI_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||||
{
|
{
|
||||||
if let Some(token) = live_config
|
if let Some(env_obj) = provider
|
||||||
.get("env")
|
.settings_config
|
||||||
.and_then(|v| v.get("GEMINI_API_KEY"))
|
.get_mut("env")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_object_mut())
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
|
||||||
{
|
{
|
||||||
if let Some(env_obj) = provider
|
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
||||||
.settings_config
|
} else {
|
||||||
.get_mut("env")
|
if provider.settings_config.is_null() {
|
||||||
.and_then(|v| v.as_object_mut())
|
provider.settings_config = json!({});
|
||||||
{
|
}
|
||||||
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
|
||||||
} else {
|
|
||||||
if provider.settings_config.is_null() {
|
|
||||||
provider.settings_config = json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||||
root.insert(
|
root.insert("env".to_string(), json!({ "GEMINI_API_KEY": token }));
|
||||||
"env".to_string(),
|
} else {
|
||||||
json!({ "GEMINI_API_KEY": token }),
|
log::warn!(
|
||||||
);
|
|
||||||
} 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})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1199,38 +1220,41 @@ 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 {
|
||||||
if let Ok(Some(mut provider)) =
|
let Some(mut provider) = self
|
||||||
self.db.get_provider_by_id(&provider_id, "grokbuild")
|
.db
|
||||||
|
.get_provider_by_id(&provider_id, "grokbuild")
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("读取 Grok Build 供应商 '{provider_id}' 失败: {error}")
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err(format!("Grok Build 当前供应商不存在: {provider_id}"));
|
||||||
|
};
|
||||||
|
let observed_fingerprint = provider_row_fingerprint(&provider);
|
||||||
|
let live_config_toml = live_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(token) =
|
||||||
|
crate::grok_config::extract_inline_api_key(live_config_toml)
|
||||||
{
|
{
|
||||||
let live_config_toml = live_config
|
if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER {
|
||||||
.get("config")
|
if let Some(provider_config) = provider
|
||||||
.and_then(Value::as_str)
|
.settings_config
|
||||||
.unwrap_or_default();
|
.get("config")
|
||||||
if let Some(token) =
|
.and_then(Value::as_str)
|
||||||
crate::grok_config::extract_inline_api_key(live_config_toml)
|
{
|
||||||
{
|
let updated =
|
||||||
if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER {
|
crate::grok_config::update_api_key(provider_config, &token)
|
||||||
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 Token 到数据库失败: {e}")
|
format!("更新 Grok Build API Key 失败: {e}")
|
||||||
})?;
|
})?;
|
||||||
}
|
provider.settings_config["config"] = json!(updated);
|
||||||
|
self.persist_synced_live_token(
|
||||||
|
"grokbuild",
|
||||||
|
&provider_id,
|
||||||
|
observed_fingerprint,
|
||||||
|
provider,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3813,7 +3837,7 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("claude", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -3999,7 +4023,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("cn_official".to_string());
|
provider.category = Some("cn_official".to_string());
|
||||||
db.save_provider("codex", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -4085,7 +4109,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -4146,7 +4170,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &official)
|
db.reconcile_provider_fixture("codex", &official)
|
||||||
.expect("save official provider");
|
.expect("save official provider");
|
||||||
|
|
||||||
let mut third_party = Provider::with_id(
|
let mut third_party = Provider::with_id(
|
||||||
@@ -4165,7 +4189,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
third_party.category = Some("custom".to_string());
|
third_party.category = Some("custom".to_string());
|
||||||
db.save_provider("codex", &third_party)
|
db.reconcile_provider_fixture("codex", &third_party)
|
||||||
.expect("save third-party provider");
|
.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");
|
||||||
@@ -4314,7 +4338,8 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &official).expect("save official");
|
db.reconcile_provider_fixture("codex", &official)
|
||||||
|
.expect("save official");
|
||||||
db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID)
|
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(
|
||||||
@@ -4392,7 +4417,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -4472,7 +4497,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -4584,7 +4609,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -4702,7 +4727,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.save_provider("codex", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -4838,7 +4863,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("cn_official".to_string());
|
provider.category = Some("cn_official".to_string());
|
||||||
db.save_provider("codex", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -5315,7 +5340,7 @@ model = "gpt-5.1-codex"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("claude", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -5371,7 +5396,7 @@ model = "gpt-5.1-codex"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("claude", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -5407,6 +5432,54 @@ model = "gpt-5.1-codex"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synced_live_token_cannot_revert_a_concurrent_provider_edit() {
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let service = ProxyService::new(db.clone());
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"p1".to_string(),
|
||||||
|
"Original".to_string(),
|
||||||
|
json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "stale" } }),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.reconcile_provider_fixture("claude", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
|
||||||
|
let observed = db
|
||||||
|
.get_provider_by_id("p1", "claude")
|
||||||
|
.expect("read observed provider")
|
||||||
|
.expect("provider exists");
|
||||||
|
let fingerprint = provider_row_fingerprint(&observed);
|
||||||
|
let mut token_update = observed.clone();
|
||||||
|
token_update.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] = json!("from-live");
|
||||||
|
|
||||||
|
let mut concurrent_edit = observed;
|
||||||
|
concurrent_edit.name = "Concurrent user edit".to_string();
|
||||||
|
if let Some(meta) = concurrent_edit.meta.as_mut() {
|
||||||
|
meta.custom_endpoints.clear();
|
||||||
|
}
|
||||||
|
let input = provider_to_mutation_input(concurrent_edit);
|
||||||
|
let key = crate::database::ProviderKey::new("claude", "p1").expect("provider key");
|
||||||
|
let row = crate::database::ProviderRowUpdate::from_input(&input).expect("row update");
|
||||||
|
db.update_provider(&key, &row)
|
||||||
|
.expect("persist concurrent user edit");
|
||||||
|
|
||||||
|
let error = service
|
||||||
|
.persist_synced_live_token("claude", "p1", fingerprint, token_update)
|
||||||
|
.expect_err("stale token sync must fail closed");
|
||||||
|
assert!(error.contains("changed since it was read"), "{error}");
|
||||||
|
|
||||||
|
let saved = db
|
||||||
|
.get_provider_by_id("p1", "claude")
|
||||||
|
.expect("read saved provider")
|
||||||
|
.expect("saved provider exists");
|
||||||
|
assert_eq!(saved.name, "Concurrent user edit");
|
||||||
|
assert_eq!(
|
||||||
|
saved.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"],
|
||||||
|
json!("stale")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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() {
|
||||||
@@ -5436,9 +5509,9 @@ model = "gpt-5.1-codex"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("claude", &provider_a)
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("claude", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -5511,9 +5584,9 @@ model = "gpt-5.1-codex"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.save_provider("claude", &provider_a)
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("claude", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -5662,11 +5735,11 @@ model = "gpt-5.1-codex"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.save_provider("claude", &provider_a)
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("claude", &provider_b)
|
db.reconcile_provider_fixture("claude", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.save_provider("claude", &provider_c)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -5749,9 +5822,9 @@ model = "gpt-5.1-codex"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.save_provider("claude", &provider_a)
|
db.reconcile_provider_fixture("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("claude", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -6049,9 +6122,9 @@ requires_openai_auth = true
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.save_provider("codex", &provider_a)
|
db.reconcile_provider_fixture("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("codex", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -6224,9 +6297,9 @@ requires_openai_auth = true
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
db.save_provider("codex", &provider_a)
|
db.reconcile_provider_fixture("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("codex", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -6468,9 +6541,9 @@ requires_openai_auth = true
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
db.save_provider("codex", &provider_a)
|
db.reconcile_provider_fixture("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("codex", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -6604,9 +6677,9 @@ requires_openai_auth = true
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.save_provider("codex", &provider_a)
|
db.reconcile_provider_fixture("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("codex", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -6686,9 +6759,9 @@ requires_openai_auth = true
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("codex", &provider_a)
|
db.reconcile_provider_fixture("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("codex", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -6970,7 +7043,7 @@ requires_openai_auth = true
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("claude", &provider)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -7224,9 +7297,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.save_provider("grokbuild", &provider_a)
|
db.reconcile_provider_fixture("grokbuild", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("grokbuild", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
@@ -7291,9 +7364,9 @@ experimental_bearer_token = "PROXY_MANAGED"
|
|||||||
json!({ "config": "not valid toml = [" }),
|
json!({ "config": "not valid toml = [" }),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.save_provider("grokbuild", &provider_a)
|
db.reconcile_provider_fixture("grokbuild", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.save_provider("grokbuild", &provider_b)
|
db.reconcile_provider_fixture("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");
|
||||||
|
|||||||
@@ -2374,38 +2374,31 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
||||||
///
|
///
|
||||||
/// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序:
|
/// 兼容三种情况:
|
||||||
/// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先;
|
/// 1. `skills/foo` 这类直接相对路径;
|
||||||
/// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
|
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
|
||||||
/// 3. 兜底:仓库根本身含 `SKILL.md`。
|
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。
|
||||||
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() && direct.join("SKILL.md").is_file() {
|
if direct.is_dir() {
|
||||||
return Some(direct);
|
return Some(direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md)
|
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
|
||||||
if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
|
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill directory '{}' not found at direct path, using fallback: {}",
|
"Skill directory '{}' not found at direct path, using fallback: {}",
|
||||||
install_name,
|
target_name,
|
||||||
found.display()
|
found.display()
|
||||||
);
|
);
|
||||||
return Some(found);
|
return Some(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 兜底:仓库根本身是 skill
|
if root.is_dir() && root.join("SKILL.md").exists() {
|
||||||
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",
|
||||||
install_name,
|
target_name,
|
||||||
);
|
);
|
||||||
return Some(root.to_path_buf());
|
return Some(root.to_path_buf());
|
||||||
}
|
}
|
||||||
@@ -4459,74 +4452,4 @@ 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 #4141:ast-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 layout:skills/category/foo/SKILL.md(depth 3,find_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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, 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: i64,
|
pub added_at: Option<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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ use std::fs;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService,
|
AppType, InstalledSkill, McpServer, McpService, NewProviderAggregate, ProfilePayload,
|
||||||
Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService,
|
ProfileScope, ProfileService, Prompt, PromptService, Provider, ProviderService, SkillApps,
|
||||||
|
SkillService,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
use support::{create_test_state, ensure_test_home, new_provider_input, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
fn claude_provider(id: &str, token: &str) -> Provider {
|
fn claude_provider(id: &str, token: &str) -> Provider {
|
||||||
Provider::with_id(
|
Provider::with_id(
|
||||||
@@ -107,34 +108,41 @@ 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 ----
|
||||||
state
|
ProviderService::add(
|
||||||
.db
|
&state,
|
||||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
AppType::Claude,
|
||||||
.expect("save provider p1");
|
new_provider_input(claude_provider("p1", "key-1")),
|
||||||
state
|
false,
|
||||||
.db
|
)
|
||||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
.expect("create provider p1");
|
||||||
.expect("save provider p2");
|
ProviderService::add(
|
||||||
|
&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 对它不适用)
|
||||||
state
|
for provider in [
|
||||||
.db
|
desktop_provider("d1", "dk-1"),
|
||||||
.save_provider(
|
desktop_provider("d2", "dk-2"),
|
||||||
AppType::ClaudeDesktop.as_str(),
|
] {
|
||||||
&desktop_provider("d1", "dk-1"),
|
state
|
||||||
)
|
.db
|
||||||
.expect("save desktop provider d1");
|
.create_provider(
|
||||||
state
|
NewProviderAggregate::from_input(
|
||||||
.db
|
AppType::ClaudeDesktop.as_str(),
|
||||||
.save_provider(
|
new_provider_input(provider),
|
||||||
AppType::ClaudeDesktop.as_str(),
|
)
|
||||||
&desktop_provider("d2", "dk-2"),
|
.expect("build typed desktop create"),
|
||||||
)
|
)
|
||||||
.expect("save desktop provider d2");
|
.expect("create desktop provider");
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||||||
@@ -287,10 +295,13 @@ fn shared_profile_sides_are_isolated_and_mergeable() {
|
|||||||
let state = create_test_state().expect("create test state");
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
// 种子:Claude 侧有当前供应商 + 启用的 MCP
|
// 种子:Claude 侧有当前供应商 + 启用的 MCP
|
||||||
state
|
ProviderService::add(
|
||||||
.db
|
&state,
|
||||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
AppType::Claude,
|
||||||
.expect("save provider p1");
|
new_provider_input(claude_provider("p1", "key-1")),
|
||||||
|
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")
|
||||||
@@ -496,14 +507,20 @@ 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 ----
|
||||||
state
|
ProviderService::add(
|
||||||
.db
|
&state,
|
||||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
AppType::Claude,
|
||||||
.expect("save provider p1");
|
new_provider_input(claude_provider("p1", "key-1")),
|
||||||
state
|
false,
|
||||||
.db
|
)
|
||||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
.expect("create provider p1");
|
||||||
.expect("save provider p2");
|
ProviderService::add(
|
||||||
|
&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")
|
||||||
@@ -665,17 +682,13 @@ 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());
|
||||||
state
|
ProviderService::add(&state, AppType::Claude, new_provider_input(custom1), false)
|
||||||
.db
|
.expect("create custom1 provider");
|
||||||
.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());
|
||||||
state
|
ProviderService::add(&state, AppType::Claude, new_provider_input(custom2), false)
|
||||||
.db
|
.expect("create custom2 provider");
|
||||||
.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");
|
||||||
@@ -757,20 +770,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");
|
||||||
|
|
||||||
state
|
ProviderService::add(
|
||||||
.db
|
&state,
|
||||||
.save_provider(
|
AppType::ClaudeDesktop,
|
||||||
AppType::ClaudeDesktop.as_str(),
|
new_provider_input(desktop_provider("d1", "dk-1")),
|
||||||
&desktop_provider("d1", "dk-1"),
|
false,
|
||||||
)
|
)
|
||||||
.expect("save desktop provider d1");
|
.expect("create desktop provider d1");
|
||||||
state
|
ProviderService::add(
|
||||||
.db
|
&state,
|
||||||
.save_provider(
|
AppType::ClaudeDesktop,
|
||||||
AppType::ClaudeDesktop.as_str(),
|
new_provider_input(desktop_provider("d2", "dk-2")),
|
||||||
&desktop_provider("d2", "dk-2"),
|
false,
|
||||||
)
|
)
|
||||||
.expect("save desktop provider d2");
|
.expect("create desktop provider d2");
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||||||
|
|||||||
@@ -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, reset_test_fs, test_mutex,
|
ensure_test_home, new_provider_input, 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");
|
||||||
state
|
ProviderService::add(
|
||||||
.db
|
&state,
|
||||||
.save_provider(
|
AppType::GrokBuild,
|
||||||
AppType::GrokBuild.as_str(),
|
new_provider_input(Provider::with_id(
|
||||||
&Provider::with_id(
|
"relay".to_string(),
|
||||||
"relay".to_string(),
|
"Relay".to_string(),
|
||||||
"Relay".to_string(),
|
json!({ "config": next_config }),
|
||||||
json!({ "config": next_config }),
|
None,
|
||||||
None,
|
)),
|
||||||
),
|
false,
|
||||||
)
|
)
|
||||||
.expect("save second Grok Build provider");
|
.expect("create 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");
|
||||||
|
|||||||
@@ -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, reset_test_fs, test_mutex,
|
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn sanitize_provider_name(name: &str) -> String {
|
fn sanitize_provider_name(name: &str) -> String {
|
||||||
@@ -3084,10 +3084,8 @@ fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_ba
|
|||||||
taken_over_live.clone(),
|
taken_over_live.clone(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
state
|
ProviderService::add(&state, AppType::Claude, new_provider_input(provider), false)
|
||||||
.db
|
.expect("create placeholder provider");
|
||||||
.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,7 +1,31 @@
|
|||||||
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::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
|
use cc_switch_lib::{
|
||||||
|
update_settings, AppSettings, AppState, Database, MultiAppConfig, Provider,
|
||||||
|
ProviderMutationInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build the public write DTO explicitly for integration tests. Keeping this
|
||||||
|
/// conversion test-only avoids reintroducing a production `From<Provider>`
|
||||||
|
/// path from hydrated read projections to provider mutations.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new_provider_input(provider: Provider) -> ProviderMutationInput {
|
||||||
|
ProviderMutationInput {
|
||||||
|
id: provider.id,
|
||||||
|
name: provider.name,
|
||||||
|
settings_config: provider.settings_config,
|
||||||
|
website_url: provider.website_url,
|
||||||
|
category: provider.category,
|
||||||
|
created_at: provider.created_at,
|
||||||
|
sort_index: provider.sort_index,
|
||||||
|
notes: provider.notes,
|
||||||
|
meta: provider.meta,
|
||||||
|
icon: provider.icon,
|
||||||
|
icon_color: provider.icon_color,
|
||||||
|
in_failover_queue: provider.in_failover_queue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
||||||
pub fn ensure_test_home() -> &'static Path {
|
pub fn ensure_test_home() -> &'static Path {
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription";
|
|
||||||
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
|
||||||
|
|
||||||
interface CodexOauthAccountQuotaProps {
|
|
||||||
/** cc-switch 自管的 ChatGPT 账号 ID */
|
|
||||||
accountId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置 → 认证中心里,单个 ChatGPT (Codex OAuth) 账号的用量展示。
|
|
||||||
*
|
|
||||||
* 直接按 accountId 查询 cc-switch 自管 OAuth token 的订阅额度,复用
|
|
||||||
* `SubscriptionQuotaView` 的展开布局(进度条 + 重置倒计时 + 刷新按钮),
|
|
||||||
* 因此与供应商卡片里的额度展示保持完全一致的观感与状态处理。
|
|
||||||
*
|
|
||||||
* 面板打开时拉取一次,不轮询;用户可点卡片内的刷新按钮手动更新。
|
|
||||||
*/
|
|
||||||
const CodexOauthAccountQuota: React.FC<CodexOauthAccountQuotaProps> = ({
|
|
||||||
accountId,
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
data: quota,
|
|
||||||
isFetching: loading,
|
|
||||||
refetch,
|
|
||||||
} = useCodexOauthQuotaByAccountId(accountId, {
|
|
||||||
enabled: true,
|
|
||||||
autoQuery: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。
|
|
||||||
// 用量请求是异步的(Tauri invoke + React Query),加载期间给一个
|
|
||||||
// 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位,
|
|
||||||
// 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。
|
|
||||||
if (loading && !quota) {
|
|
||||||
return (
|
|
||||||
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubscriptionQuotaView
|
|
||||||
quota={quota}
|
|
||||||
loading={loading}
|
|
||||||
refetch={refetch}
|
|
||||||
appIdForExpiredHint="codex_oauth"
|
|
||||||
inline={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CodexOauthAccountQuota;
|
|
||||||
@@ -24,12 +24,9 @@ import {
|
|||||||
} from "lucide-react";
|
} 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;
|
||||||
/** 账号选择回调 */
|
/** 账号选择回调 */
|
||||||
@@ -48,7 +45,6 @@ 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,
|
||||||
@@ -182,52 +178,47 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
|||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="space-y-2 p-2 rounded-md border bg-muted/30"
|
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<User className="h-5 w-5 text-muted-foreground" />
|
||||||
<User className="h-5 w-5 text-muted-foreground" />
|
<span className="text-sm font-medium">{account.login}</span>
|
||||||
<span className="text-sm font-medium">{account.login}</span>
|
{defaultAccountId === account.id && (
|
||||||
{defaultAccountId === account.id && (
|
<Badge variant="secondary" className="text-xs">
|
||||||
<Badge variant="secondary" className="text-xs">
|
{t("codexOauth.defaultAccount", "默认")}
|
||||||
{t("codexOauth.defaultAccount", "默认")}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
{selectedAccountId === account.id && (
|
||||||
{selectedAccountId === account.id && (
|
<Badge variant="outline" className="text-xs">
|
||||||
<Badge variant="outline" className="text-xs">
|
{t("codexOauth.selected", "已选中")}
|
||||||
{t("codexOauth.selected", "已选中")}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
{defaultAccountId !== account.id && (
|
||||||
{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="icon"
|
size="sm"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
className="h-7 px-2 text-xs text-muted-foreground"
|
||||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
onClick={() => setDefaultAccount(account.id)}
|
||||||
disabled={isRemovingAccount}
|
disabled={isSettingDefaultAccount}
|
||||||
title={t("codexOauth.removeAccount", "移除账号")}
|
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
{t("codexOauth.setAsDefault", "设为默认")}
|
||||||
</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,8 +1537,16 @@ function ProviderFormFull({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseMeta: ProviderMeta | undefined =
|
const metaSource = payload.meta ?? initialData?.meta;
|
||||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
const baseMeta: ProviderMeta | undefined = metaSource
|
||||||
|
? { ...metaSource }
|
||||||
|
: undefined;
|
||||||
|
// Existing-provider edits never own endpoint membership. The backend
|
||||||
|
// rejects endpoint-bearing update payloads; add/remove/touch use their
|
||||||
|
// dedicated commands and remain safe from stale form snapshots.
|
||||||
|
if (isEditMode && baseMeta) {
|
||||||
|
delete baseMeta.custom_endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
||||||
const providerType = presetProviderType || initialData?.meta?.providerType;
|
const providerType = presetProviderType || initialData?.meta?.providerType;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CodexOAuthSection showAccountQuota />
|
<CodexOAuthSection />
|
||||||
</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">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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)
|
||||||
@@ -362,7 +363,10 @@ export function useProviderActions(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await providersApi.update(updatedProvider, activeApp);
|
await providersApi.update(
|
||||||
|
toProviderUpdateInput(updatedProvider),
|
||||||
|
activeApp,
|
||||||
|
);
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["providers", activeApp],
|
queryKey: ["providers", activeApp],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Provider } from "@/types";
|
||||||
|
import { toProviderUpdateInput } from "./providers";
|
||||||
|
|
||||||
|
describe("toProviderUpdateInput", () => {
|
||||||
|
it("removes hydrated endpoints and row-state fields from update payloads", () => {
|
||||||
|
const hydrated: Provider = {
|
||||||
|
id: "endpoint-provider",
|
||||||
|
name: "Endpoint provider",
|
||||||
|
settingsConfig: { env: { API_KEY: "secret" } },
|
||||||
|
createdAt: 1_700_000_000,
|
||||||
|
sortIndex: 7,
|
||||||
|
inFailoverQueue: true,
|
||||||
|
meta: {
|
||||||
|
custom_endpoints: {
|
||||||
|
"https://one.example": {
|
||||||
|
url: "https://one.example",
|
||||||
|
addedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usage_script: {
|
||||||
|
enabled: true,
|
||||||
|
language: "javascript",
|
||||||
|
code: "{}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = toProviderUpdateInput(hydrated);
|
||||||
|
|
||||||
|
expect(update).not.toHaveProperty("createdAt");
|
||||||
|
expect(update).not.toHaveProperty("sortIndex");
|
||||||
|
expect(update).not.toHaveProperty("inFailoverQueue");
|
||||||
|
expect(update.meta).not.toHaveProperty("custom_endpoints");
|
||||||
|
expect(update.meta?.usage_script).toEqual(hydrated.meta?.usage_script);
|
||||||
|
expect(
|
||||||
|
hydrated.meta?.custom_endpoints?.["https://one.example"].addedAt,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -12,6 +13,39 @@ 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;
|
||||||
@@ -64,7 +98,7 @@ export const providersApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
provider: Provider,
|
provider: ProviderUpdateInput,
|
||||||
appId: AppId,
|
appId: AppId,
|
||||||
originalId?: string,
|
originalId?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
|||||||
@@ -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 type { SwitchResult } from "@/lib/api/providers";
|
import { toProviderUpdateInput, 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,7 +168,11 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
originalId?: string;
|
originalId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await providersApi.update(provider, appId, originalId);
|
await providersApi.update(
|
||||||
|
toProviderUpdateInput(provider),
|
||||||
|
appId,
|
||||||
|
originalId,
|
||||||
|
);
|
||||||
return provider;
|
return provider;
|
||||||
},
|
},
|
||||||
onSuccess: async (provider, variables) => {
|
onSuccess: async (provider, variables) => {
|
||||||
|
|||||||
@@ -112,18 +112,20 @@ export interface UseCodexOauthQuotaOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codex OAuth 订阅额度查询 hook(按账号 ID)
|
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
||||||
*
|
*
|
||||||
* 直接以 cc-switch 自管的 ChatGPT 账号 ID 查询额度,供认证中心里逐个账号
|
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
||||||
* 展示用量时复用。Query key 与 `useCodexOauthQuota` 一致,绑定到同一账号的
|
* 而不是 Codex CLI 的 ~/.codex/auth.json。
|
||||||
* 供应商卡片与账号列表会自动去重共享同一份请求缓存。
|
*
|
||||||
|
* Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。
|
||||||
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
||||||
*/
|
*/
|
||||||
export function useCodexOauthQuotaByAccountId(
|
export function useCodexOauthQuota(
|
||||||
accountId: string | null,
|
meta: ProviderMeta | undefined,
|
||||||
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),
|
||||||
@@ -138,21 +140,6 @@ export function useCodexOauthQuotaByAccountId(
|
|||||||
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
@@ -38,7 +38,7 @@ export interface AppConfig {
|
|||||||
// 自定义端点配置
|
// 自定义端点配置
|
||||||
export interface CustomEndpoint {
|
export interface CustomEndpoint {
|
||||||
url: string;
|
url: string;
|
||||||
addedAt: number;
|
addedAt: number | null;
|
||||||
lastUsed?: number;
|
lastUsed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
|
|
||||||
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
useCodexOauth: vi.fn(),
|
|
||||||
renderAccountQuota: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
|
|
||||||
useCodexOauth: mocks.useCodexOauth,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/CodexOauthAccountQuota", () => ({
|
|
||||||
default: ({ accountId }: { accountId: string }) => {
|
|
||||||
mocks.renderAccountQuota(accountId);
|
|
||||||
return <div data-testid="account-quota">{accountId}</div>;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
|
||||||
CopilotAuthSection: () => <div />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
|
|
||||||
XaiOAuthSection: () => <div />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("CodexOAuthSection", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.useCodexOauth.mockReturnValue({
|
|
||||||
accounts: [
|
|
||||||
{
|
|
||||||
id: "account-1",
|
|
||||||
provider: "codex_oauth",
|
|
||||||
login: "user@example.com",
|
|
||||||
avatar_url: null,
|
|
||||||
authenticated_at: 0,
|
|
||||||
is_default: true,
|
|
||||||
github_domain: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
defaultAccountId: "account-1",
|
|
||||||
hasAnyAccount: true,
|
|
||||||
pollingState: "idle",
|
|
||||||
deviceCode: null,
|
|
||||||
error: null,
|
|
||||||
isPolling: false,
|
|
||||||
isAddingAccount: false,
|
|
||||||
isRemovingAccount: false,
|
|
||||||
isSettingDefaultAccount: false,
|
|
||||||
addAccount: vi.fn(),
|
|
||||||
removeAccount: vi.fn(),
|
|
||||||
setDefaultAccount: vi.fn(),
|
|
||||||
cancelAuth: vi.fn(),
|
|
||||||
logout: vi.fn(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render account quota by default", () => {
|
|
||||||
render(<CodexOAuthSection />);
|
|
||||||
|
|
||||||
expect(mocks.renderAccountQuota).not.toHaveBeenCalled();
|
|
||||||
expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders account quota in Auth Center", () => {
|
|
||||||
render(<AuthCenterPanel />);
|
|
||||||
|
|
||||||
expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1");
|
|
||||||
expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"manifestVersion": 1,
|
||||||
|
"codeAuthority": "src-tauri/src/database/dao/provider_write.rs",
|
||||||
|
"types": {
|
||||||
|
"ProviderKey": ["app_type", "id"],
|
||||||
|
"ProviderRowCreate": ["content", "created_at"],
|
||||||
|
"ProviderRowUpdate": [
|
||||||
|
"name",
|
||||||
|
"settings_config",
|
||||||
|
"website_url",
|
||||||
|
"category",
|
||||||
|
"notes",
|
||||||
|
"meta",
|
||||||
|
"icon",
|
||||||
|
"icon_color"
|
||||||
|
],
|
||||||
|
"NewEndpoint": ["url", "added_at", "last_used"],
|
||||||
|
"NewProviderAggregate": [
|
||||||
|
"key",
|
||||||
|
"row",
|
||||||
|
"sort_index",
|
||||||
|
"in_failover_queue",
|
||||||
|
"initial_endpoints"
|
||||||
|
],
|
||||||
|
"RenameProvider": ["source", "target_id", "row"]
|
||||||
|
},
|
||||||
|
"databaseMethods": {
|
||||||
|
"create_provider": ["NewProviderAggregate"],
|
||||||
|
"update_provider": ["ProviderKey", "ProviderRowUpdate"],
|
||||||
|
"rename_db_only_additive_provider": ["RenameProvider"],
|
||||||
|
"add_provider_endpoint": ["ProviderKey", "NewEndpoint"],
|
||||||
|
"remove_provider_endpoint": ["ProviderKey", "str"],
|
||||||
|
"touch_provider_endpoint": ["ProviderKey", "str", "i64"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user