mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8e4cae41b | |||
| eb356e15bd | |||
| f38722a440 | |||
| bc180a3d9d | |||
| c7a2bff78b | |||
| d52ab6c5f4 | |||
| 0f3991efc3 |
Generated
-1
@@ -799,7 +799,6 @@ dependencies = [
|
|||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
"sha2",
|
"sha2",
|
||||||
"syn 2.0.117",
|
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
|||||||
@@ -116,4 +116,3 @@ strip = "symbols"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serial_test = "3"
|
serial_test = "3"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
syn = { version = "2", features = ["full", "visit"] }
|
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ use crate::codex_state_db::codex_state_db_paths;
|
|||||||
use crate::config::{atomic_write, copy_file, get_app_config_dir};
|
use crate::config::{atomic_write, copy_file, get_app_config_dir};
|
||||||
use crate::database::{is_official_seed_id, Database};
|
use crate::database::{is_official_seed_id, Database};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::provider::{
|
|
||||||
provider_row_fingerprint, provider_to_mutation_input,
|
|
||||||
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
|
||||||
};
|
|
||||||
use crate::settings::{
|
use crate::settings::{
|
||||||
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
||||||
CodexThirdPartyHistoryProviderBucketMigration,
|
CodexThirdPartyHistoryProviderBucketMigration,
|
||||||
@@ -667,8 +663,7 @@ fn migrate_codex_provider_templates_to_custom(
|
|||||||
let providers = db.get_all_providers("codex")?;
|
let providers = db.get_all_providers("codex")?;
|
||||||
let mut migrated_provider_ids = Vec::new();
|
let mut migrated_provider_ids = Vec::new();
|
||||||
|
|
||||||
for (_, mut provider) in providers {
|
for (_, provider) in providers {
|
||||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
|
||||||
if provider.category.as_deref() == Some("official")
|
if provider.category.as_deref() == Some("official")
|
||||||
|| is_official_seed_id(&provider.id)
|
|| is_official_seed_id(&provider.id)
|
||||||
|| provider.is_codex_oauth()
|
|| provider.is_codex_oauth()
|
||||||
@@ -699,21 +694,8 @@ fn migrate_codex_provider_templates_to_custom(
|
|||||||
};
|
};
|
||||||
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
|
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
|
||||||
obj.insert("config".to_string(), Value::String(migrated_config_text));
|
obj.insert("config".to_string(), Value::String(migrated_config_text));
|
||||||
let provider_id = provider.id.clone();
|
db.update_provider_settings_config("codex", &provider.id, &settings)?;
|
||||||
provider.settings_config = settings;
|
migrated_provider_ids.push(provider.id);
|
||||||
if let Some(meta) = provider.meta.as_mut() {
|
|
||||||
meta.custom_endpoints.clear();
|
|
||||||
}
|
|
||||||
let input = provider_to_mutation_input(provider);
|
|
||||||
reconcile_provider_record_with_precondition(
|
|
||||||
db,
|
|
||||||
"codex",
|
|
||||||
input,
|
|
||||||
ReconcilePrecondition::ExpectPresent {
|
|
||||||
fingerprint: observed_fingerprint,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
migrated_provider_ids.push(provider_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
Ok(CodexProviderTemplateBucketMigrationOutcome {
|
||||||
@@ -1457,8 +1439,7 @@ base_url = "https://proxy.example/v1"
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
for provider in providers {
|
for provider in providers {
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut official = Provider::with_id(
|
let mut official = Provider::with_id(
|
||||||
@@ -1468,8 +1449,7 @@ base_url = "https://proxy.example/v1"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &official)
|
db.save_provider("codex", &official).expect("save official");
|
||||||
.expect("save official");
|
|
||||||
|
|
||||||
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2191,10 +2171,9 @@ base_url = "https://proxy.example/v1"
|
|||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &third_party)
|
db.save_provider("codex", &third_party)
|
||||||
.expect("save third-party");
|
.expect("save third-party");
|
||||||
db.reconcile_provider_fixture("codex", &official)
|
db.save_provider("codex", &official).expect("save official");
|
||||||
.expect("save official");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(ids.contains("rightcode"));
|
assert!(ids.contains("rightcode"));
|
||||||
@@ -2217,8 +2196,7 @@ base_url = "https://proxy.example/v1"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2238,8 +2216,7 @@ base_url = "https://proxy.example/v1"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2267,8 +2244,7 @@ model_provider = "my-private-relay"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2288,8 +2264,7 @@ model_provider = "my-private-relay"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(ids.contains("aihubmix"));
|
assert!(ids.contains("aihubmix"));
|
||||||
@@ -2310,8 +2285,7 @@ model_provider = "my-private-relay"
|
|||||||
);
|
);
|
||||||
provider.category = Some("aggregator".to_string());
|
provider.category = Some("aggregator".to_string());
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(ids.contains("ccswitch"));
|
assert!(ids.contains("ccswitch"));
|
||||||
@@ -2343,8 +2317,7 @@ model = "gpt-5.4"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
|
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
|
||||||
@@ -2417,8 +2390,7 @@ base_url = "https://aihubmix.example/v1"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2474,8 +2446,7 @@ base_url = "http://localhost:8080/v1"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert!(outcome.migrated_provider_ids.is_empty());
|
assert!(outcome.migrated_provider_ids.is_empty());
|
||||||
@@ -2524,8 +2495,7 @@ base_url = "https://proxy.example/v1"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert!(outcome.migrated_provider_ids.is_empty());
|
assert!(outcome.migrated_provider_ids.is_empty());
|
||||||
@@ -2582,8 +2552,7 @@ model_provider = "aihubmix"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
|
||||||
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
|
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
|
||||||
@@ -2632,8 +2601,7 @@ model_provider = "aihubmix"
|
|||||||
provider.category = Some("custom".to_string());
|
provider.category = Some("custom".to_string());
|
||||||
provider.created_at = Some(1);
|
provider.created_at = Some(1);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-private-relay"));
|
assert!(!ids.contains("my-private-relay"));
|
||||||
@@ -2654,8 +2622,7 @@ model_provider = "aihubmix"
|
|||||||
);
|
);
|
||||||
provider.category = Some("custom".to_string());
|
provider.category = Some("custom".to_string());
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider).expect("save provider");
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
|
||||||
assert!(!ids.contains("my-local-relay"));
|
assert!(!ids.contains("my-local-relay"));
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ use tauri::{Emitter, Manager, State};
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::commands::copilot::CopilotAuthState;
|
use crate::commands::copilot::CopilotAuthState;
|
||||||
use crate::commands::xai_oauth::XaiOAuthState;
|
use crate::commands::xai_oauth::XaiOAuthState;
|
||||||
use crate::database::NewProviderAggregate;
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMutationInput};
|
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||||
};
|
};
|
||||||
@@ -40,7 +39,7 @@ pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<S
|
|||||||
pub fn add_provider(
|
pub fn add_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
provider: ProviderMutationInput,
|
provider: Provider,
|
||||||
#[allow(non_snake_case)] addToLive: Option<bool>,
|
#[allow(non_snake_case)] addToLive: Option<bool>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
@@ -52,7 +51,7 @@ pub fn add_provider(
|
|||||||
pub fn update_provider(
|
pub fn update_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
provider: ProviderMutationInput,
|
provider: Provider,
|
||||||
#[allow(non_snake_case)] originalId: Option<String>,
|
#[allow(non_snake_case)] originalId: Option<String>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
@@ -251,13 +250,7 @@ pub fn import_claude_desktop_providers_from_claude(
|
|||||||
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.create_provider(
|
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
|
||||||
NewProviderAggregate::from_input(
|
|
||||||
AppType::ClaudeDesktop.as_str(),
|
|
||||||
crate::services::provider::provider_to_mutation_input(desktop_provider),
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ pub mod failover;
|
|||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod provider_write;
|
|
||||||
#[cfg(test)]
|
|
||||||
mod provider_write_certification;
|
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod providers_seed;
|
pub mod providers_seed;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
|||||||
@@ -1,640 +0,0 @@
|
|||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::{ProviderMeta, ProviderMutationInput};
|
|
||||||
use crate::settings::CustomEndpoint;
|
|
||||||
use rusqlite::{params, OptionalExtension, Transaction};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use super::providers::{StoredProviderRow, PROVIDER_SELECT};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ProviderKey {
|
|
||||||
app_type: String,
|
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderKey {
|
|
||||||
pub fn new(app_type: impl Into<String>, id: impl Into<String>) -> Result<Self, AppError> {
|
|
||||||
let app_type = app_type.into();
|
|
||||||
let id = id.into();
|
|
||||||
if app_type.trim().is_empty() || id.trim().is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"provider app type and id must be non-empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Self { app_type, id })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn app_type(&self) -> &str {
|
|
||||||
&self.app_type
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> &str {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ProviderRowUpdate {
|
|
||||||
name: String,
|
|
||||||
settings_config: Value,
|
|
||||||
website_url: Option<String>,
|
|
||||||
category: Option<String>,
|
|
||||||
notes: Option<String>,
|
|
||||||
meta: ProviderMeta,
|
|
||||||
icon: Option<String>,
|
|
||||||
icon_color: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderRowUpdate {
|
|
||||||
pub fn from_input(input: &ProviderMutationInput) -> Result<Self, AppError> {
|
|
||||||
let meta = input.meta.clone().unwrap_or_default();
|
|
||||||
if !meta.custom_endpoints.is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"provider update must not contain customEndpoints; use endpoint operations"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
name: input.name.clone(),
|
|
||||||
settings_config: input.settings_config.clone(),
|
|
||||||
website_url: input.website_url.clone(),
|
|
||||||
category: input.category.clone(),
|
|
||||||
notes: input.notes.clone(),
|
|
||||||
meta,
|
|
||||||
icon: input.icon.clone(),
|
|
||||||
icon_color: input.icon_color.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ProviderRowCreate {
|
|
||||||
content: ProviderRowUpdate,
|
|
||||||
created_at: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NewEndpoint {
|
|
||||||
url: String,
|
|
||||||
added_at: Option<i64>,
|
|
||||||
last_used: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewEndpoint {
|
|
||||||
pub fn new(
|
|
||||||
url: impl Into<String>,
|
|
||||||
added_at: Option<i64>,
|
|
||||||
last_used: Option<i64>,
|
|
||||||
) -> Result<Self, AppError> {
|
|
||||||
let url = url.into();
|
|
||||||
if url.trim().is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"provider endpoint URL cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
url,
|
|
||||||
added_at,
|
|
||||||
last_used,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn now(url: impl Into<String>) -> Result<Self, AppError> {
|
|
||||||
Self::new(url, Some(chrono::Utc::now().timestamp_millis()), None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<CustomEndpoint> for NewEndpoint {
|
|
||||||
type Error = AppError;
|
|
||||||
|
|
||||||
fn try_from(endpoint: CustomEndpoint) -> Result<Self, Self::Error> {
|
|
||||||
Self::new(endpoint.url, endpoint.added_at, endpoint.last_used)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NewProviderAggregate {
|
|
||||||
key: ProviderKey,
|
|
||||||
row: ProviderRowCreate,
|
|
||||||
sort_index: Option<usize>,
|
|
||||||
in_failover_queue: bool,
|
|
||||||
initial_endpoints: Vec<NewEndpoint>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewProviderAggregate {
|
|
||||||
pub fn from_input(app_type: &str, mut input: ProviderMutationInput) -> Result<Self, AppError> {
|
|
||||||
let endpoints = input
|
|
||||||
.meta
|
|
||||||
.as_mut()
|
|
||||||
.map(|meta| std::mem::take(&mut meta.custom_endpoints))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut seen = HashSet::with_capacity(endpoints.len());
|
|
||||||
let mut initial_endpoints = Vec::with_capacity(endpoints.len());
|
|
||||||
for (key, endpoint) in endpoints {
|
|
||||||
let normalized_key = key.trim().trim_end_matches('/').to_string();
|
|
||||||
let normalized_url = endpoint.url.trim().trim_end_matches('/').to_string();
|
|
||||||
if normalized_key != normalized_url {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"provider endpoint key '{key}' must match endpoint URL '{}'",
|
|
||||||
endpoint.url
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if !seen.insert(normalized_url.clone()) {
|
|
||||||
return Err(AppError::InvalidInput(format!(
|
|
||||||
"duplicate initial provider endpoint '{}'",
|
|
||||||
endpoint.url
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
initial_endpoints.push(NewEndpoint::new(
|
|
||||||
normalized_url,
|
|
||||||
endpoint.added_at,
|
|
||||||
endpoint.last_used,
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
|
||||||
let row = ProviderRowCreate {
|
|
||||||
content: ProviderRowUpdate::from_input(&input)?,
|
|
||||||
created_at: input.created_at,
|
|
||||||
};
|
|
||||||
Ok(Self {
|
|
||||||
key,
|
|
||||||
row,
|
|
||||||
sort_index: input.sort_index,
|
|
||||||
in_failover_queue: input.in_failover_queue,
|
|
||||||
initial_endpoints,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RenameProvider {
|
|
||||||
source: ProviderKey,
|
|
||||||
target_id: String,
|
|
||||||
row: ProviderRowUpdate,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenameProvider {
|
|
||||||
pub fn from_input(
|
|
||||||
source: ProviderKey,
|
|
||||||
input: &ProviderMutationInput,
|
|
||||||
) -> Result<Self, AppError> {
|
|
||||||
if !matches!(source.app_type(), "opencode" | "openclaw") {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"provider key changes are restricted to additive OpenCode/OpenClaw providers"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if source.id() == input.id {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"provider rename requires a different target id".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if input.id.trim().is_empty() {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"provider target id must be non-empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut row = ProviderRowUpdate::from_input(input)?;
|
|
||||||
// A successful key change always remains DB-only. The service owns
|
|
||||||
// the corresponding live-file absence check, while the DAO persists
|
|
||||||
// the durable half of that invariant.
|
|
||||||
row.meta.live_config_managed = Some(false);
|
|
||||||
Ok(Self {
|
|
||||||
source,
|
|
||||||
target_id: input.id.clone(),
|
|
||||||
row,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
|
|
||||||
let settings_config = serde_json::to_string(&row.settings_config).map_err(|error| {
|
|
||||||
AppError::Database(format!("failed to serialize settings_config: {error}"))
|
|
||||||
})?;
|
|
||||||
let meta = serde_json::to_string(&row.meta).map_err(|error| {
|
|
||||||
AppError::Database(format!("failed to serialize provider meta: {error}"))
|
|
||||||
})?;
|
|
||||||
Ok((settings_config, meta))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_row(
|
|
||||||
tx: &Transaction<'_>,
|
|
||||||
key: &ProviderKey,
|
|
||||||
row: &ProviderRowUpdate,
|
|
||||||
created_at: Option<i64>,
|
|
||||||
sort_index: Option<usize>,
|
|
||||||
is_current: bool,
|
|
||||||
in_failover_queue: bool,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let (settings_config, meta) = encode_row(row)?;
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO providers (
|
|
||||||
id, app_type, name, settings_config, website_url, category,
|
|
||||||
created_at, sort_index, notes, icon, icon_color, meta,
|
|
||||||
is_current, in_failover_queue
|
|
||||||
) VALUES (
|
|
||||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
|
|
||||||
)",
|
|
||||||
params![
|
|
||||||
key.id,
|
|
||||||
key.app_type,
|
|
||||||
row.name,
|
|
||||||
settings_config,
|
|
||||||
row.website_url,
|
|
||||||
row.category,
|
|
||||||
created_at,
|
|
||||||
sort_index,
|
|
||||||
row.notes,
|
|
||||||
row.icon,
|
|
||||||
row.icon_color,
|
|
||||||
meta,
|
|
||||||
is_current,
|
|
||||||
in_failover_queue,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|error| match &error {
|
|
||||||
rusqlite::Error::SqliteFailure(code, _)
|
|
||||||
if matches!(
|
|
||||||
code.extended_code,
|
|
||||||
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
|
||||||
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
AppError::Conflict(format!(
|
|
||||||
"provider '{}/{}' already exists",
|
|
||||||
key.app_type, key.id
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => AppError::Database(error.to_string()),
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_endpoint(
|
|
||||||
tx: &Transaction<'_>,
|
|
||||||
key: &ProviderKey,
|
|
||||||
endpoint: &NewEndpoint,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO provider_endpoints
|
|
||||||
(provider_id, app_type, url, added_at, last_used)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
params![
|
|
||||||
key.id,
|
|
||||||
key.app_type,
|
|
||||||
endpoint.url,
|
|
||||||
endpoint.added_at,
|
|
||||||
endpoint.last_used
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exact aggregate replacement is sealed inside the DAO parent module. The
|
|
||||||
/// catalog compensation coordinator introduced with the ordered mutation
|
|
||||||
/// pipeline is the only intended caller.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
// The certification contract keeps immutable creation time separate from the
|
|
||||||
// mutable row DTO and calls this sealed helper directly with the full snapshot.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(super) fn restore_provider_aggregate_on_tx(
|
|
||||||
tx: &Transaction<'_>,
|
|
||||||
key: &ProviderKey,
|
|
||||||
row: &ProviderRowUpdate,
|
|
||||||
created_at: Option<i64>,
|
|
||||||
sort_index: Option<usize>,
|
|
||||||
is_current: bool,
|
|
||||||
in_failover_queue: bool,
|
|
||||||
endpoints: &[NewEndpoint],
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let updated = update_row(tx, key, row)?;
|
|
||||||
if updated == 0 {
|
|
||||||
insert_row(
|
|
||||||
tx,
|
|
||||||
key,
|
|
||||||
row,
|
|
||||||
created_at,
|
|
||||||
sort_index,
|
|
||||||
is_current,
|
|
||||||
in_failover_queue,
|
|
||||||
)?;
|
|
||||||
} else {
|
|
||||||
// Exact compensation is the only path allowed to restore immutable
|
|
||||||
// creation time after a prior aggregate mutation.
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3",
|
|
||||||
params![created_at, key.id, key.app_type],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
}
|
|
||||||
tx.execute(
|
|
||||||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
|
|
||||||
params![key.id, key.app_type],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
for endpoint in endpoints {
|
|
||||||
insert_endpoint(tx, key, endpoint)?;
|
|
||||||
}
|
|
||||||
// State and order are maintained by their dedicated authorities. Exact
|
|
||||||
// compensation may restore their captured values without exposing them in
|
|
||||||
// ProviderRowUpdate.
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE providers
|
|
||||||
SET sort_index = ?1, is_current = ?2, in_failover_queue = ?3
|
|
||||||
WHERE id = ?4 AND app_type = ?5",
|
|
||||||
params![
|
|
||||||
sort_index,
|
|
||||||
is_current,
|
|
||||||
in_failover_queue,
|
|
||||||
key.id,
|
|
||||||
key.app_type
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_row(
|
|
||||||
tx: &Transaction<'_>,
|
|
||||||
key: &ProviderKey,
|
|
||||||
row: &ProviderRowUpdate,
|
|
||||||
) -> Result<usize, AppError> {
|
|
||||||
let (settings_config, meta) = encode_row(row)?;
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE providers SET
|
|
||||||
name = ?1,
|
|
||||||
settings_config = ?2,
|
|
||||||
website_url = ?3,
|
|
||||||
category = ?4,
|
|
||||||
notes = ?5,
|
|
||||||
icon = ?6,
|
|
||||||
icon_color = ?7,
|
|
||||||
meta = ?8
|
|
||||||
WHERE id = ?9 AND app_type = ?10",
|
|
||||||
params![
|
|
||||||
row.name,
|
|
||||||
settings_config,
|
|
||||||
row.website_url,
|
|
||||||
row.category,
|
|
||||||
row.notes,
|
|
||||||
row.icon,
|
|
||||||
row.icon_color,
|
|
||||||
meta,
|
|
||||||
key.id,
|
|
||||||
key.app_type,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
pub fn create_provider(&self, input: NewProviderAggregate) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
insert_row(
|
|
||||||
&tx,
|
|
||||||
&input.key,
|
|
||||||
&input.row.content,
|
|
||||||
input.row.created_at,
|
|
||||||
input.sort_index,
|
|
||||||
false,
|
|
||||||
input.in_failover_queue,
|
|
||||||
)?;
|
|
||||||
for endpoint in &input.initial_endpoints {
|
|
||||||
insert_endpoint(&tx, &input.key, endpoint)?;
|
|
||||||
}
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_provider(
|
|
||||||
&self,
|
|
||||||
key: &ProviderKey,
|
|
||||||
row: &ProviderRowUpdate,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
if update_row(&tx, key, row)? != 1 {
|
|
||||||
return Err(AppError::NotFound(format!(
|
|
||||||
"provider '{}/{}'",
|
|
||||||
key.app_type, key.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn update_provider_if_content_fingerprint(
|
|
||||||
&self,
|
|
||||||
key: &ProviderKey,
|
|
||||||
expected_fingerprint: &str,
|
|
||||||
row: &ProviderRowUpdate,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
let current = tx
|
|
||||||
.query_row(
|
|
||||||
&format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"),
|
|
||||||
params![key.id, key.app_type],
|
|
||||||
StoredProviderRow::from_row,
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))?
|
|
||||||
.decode(key.app_type())?;
|
|
||||||
if current.row_content_fingerprint() != expected_fingerprint {
|
|
||||||
return Err(AppError::Conflict(format!(
|
|
||||||
"provider '{}/{}' changed since it was read",
|
|
||||||
key.app_type, key.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if update_row(&tx, key, row)? != 1 {
|
|
||||||
return Err(AppError::NotFound(format!(
|
|
||||||
"provider '{}/{}'",
|
|
||||||
key.app_type, key.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn rename_db_only_additive_provider(
|
|
||||||
&self,
|
|
||||||
input: RenameProvider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
let source_state = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT sort_index, is_current, in_failover_queue, category, created_at, meta
|
|
||||||
FROM providers
|
|
||||||
WHERE id = ?1 AND app_type = ?2",
|
|
||||||
params![input.source.id, input.source.app_type],
|
|
||||||
|row| {
|
|
||||||
Ok((
|
|
||||||
row.get::<_, Option<usize>>(0)?,
|
|
||||||
row.get::<_, bool>(1)?,
|
|
||||||
row.get::<_, bool>(2)?,
|
|
||||||
row.get::<_, Option<String>>(3)?,
|
|
||||||
row.get::<_, Option<i64>>(4)?,
|
|
||||||
row.get::<_, String>(5)?,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::NotFound(format!(
|
|
||||||
"provider '{}/{}'",
|
|
||||||
input.source.app_type, input.source.id
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if matches!(source_state.3.as_deref(), Some("omo" | "omo-slim")) {
|
|
||||||
return Err(AppError::InvalidInput(
|
|
||||||
"OMO/OMO Slim providers cannot be renamed".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let source_meta: ProviderMeta = if source_state.5.trim().is_empty() {
|
|
||||||
ProviderMeta::default()
|
|
||||||
} else {
|
|
||||||
serde_json::from_str(&source_state.5).map_err(|error| {
|
|
||||||
AppError::Database(format!(
|
|
||||||
"invalid meta for provider '{}/{}': {error}",
|
|
||||||
input.source.app_type, input.source.id
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
if source_meta.live_config_managed == Some(true) {
|
|
||||||
return Err(AppError::Conflict(format!(
|
|
||||||
"provider '{}/{}' became live-managed before rename",
|
|
||||||
input.source.app_type, input.source.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let target = ProviderKey::new(&input.source.app_type, &input.target_id)?;
|
|
||||||
insert_row(
|
|
||||||
&tx,
|
|
||||||
&target,
|
|
||||||
&input.row,
|
|
||||||
source_state.4,
|
|
||||||
source_state.0,
|
|
||||||
source_state.1,
|
|
||||||
source_state.2,
|
|
||||||
)?;
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO provider_endpoints
|
|
||||||
(provider_id, app_type, url, added_at, last_used)
|
|
||||||
SELECT ?1, app_type, url, added_at, last_used
|
|
||||||
FROM provider_endpoints
|
|
||||||
WHERE provider_id = ?2 AND app_type = ?3
|
|
||||||
ORDER BY id",
|
|
||||||
params![target.id, input.source.id, input.source.app_type],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
if tx
|
|
||||||
.execute(
|
|
||||||
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
|
||||||
params![input.source.id, input.source.app_type],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?
|
|
||||||
!= 1
|
|
||||||
{
|
|
||||||
return Err(AppError::NotFound(format!(
|
|
||||||
"provider '{}/{}'",
|
|
||||||
input.source.app_type, input.source.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_provider_endpoint(
|
|
||||||
&self,
|
|
||||||
key: &ProviderKey,
|
|
||||||
endpoint: NewEndpoint,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let mut conn = lock_conn!(self.conn);
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
insert_endpoint(&tx, key, &endpoint)?;
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_provider_endpoint(&self, key: &ProviderKey, url: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
if conn
|
|
||||||
.execute(
|
|
||||||
"DELETE FROM provider_endpoints
|
|
||||||
WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
|
||||||
params![key.id, key.app_type, url],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?
|
|
||||||
!= 1
|
|
||||||
{
|
|
||||||
return Err(AppError::NotFound(format!(
|
|
||||||
"provider endpoint '{}/{}/{}'",
|
|
||||||
key.app_type, key.id, url
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn touch_provider_endpoint(
|
|
||||||
&self,
|
|
||||||
key: &ProviderKey,
|
|
||||||
url: &str,
|
|
||||||
at: i64,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
if conn
|
|
||||||
.execute(
|
|
||||||
"UPDATE provider_endpoints
|
|
||||||
SET last_used = ?1
|
|
||||||
WHERE provider_id = ?2 AND app_type = ?3 AND url = ?4",
|
|
||||||
params![at, key.id, key.app_type, url],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?
|
|
||||||
!= 1
|
|
||||||
{
|
|
||||||
return Err(AppError::NotFound(format!(
|
|
||||||
"provider endpoint '{}/{}/{}'",
|
|
||||||
key.app_type, key.id, url
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn update_provider_sort_index(
|
|
||||||
&self,
|
|
||||||
key: &ProviderKey,
|
|
||||||
sort_index: usize,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
if conn
|
|
||||||
.execute(
|
|
||||||
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
|
|
||||||
params![sort_index, key.id, key.app_type],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?
|
|
||||||
!= 1
|
|
||||||
{
|
|
||||||
return Err(AppError::NotFound(format!(
|
|
||||||
"provider '{}/{}'",
|
|
||||||
key.app_type, key.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,9 +32,6 @@ mod schema;
|
|||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
// DAO 类型导出供外部使用
|
// DAO 类型导出供外部使用
|
||||||
pub use dao::provider_write::{
|
|
||||||
NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
|
|
||||||
};
|
|
||||||
pub(crate) use dao::providers_seed::{
|
pub(crate) use dao::providers_seed::{
|
||||||
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
|
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
|
||||||
GROKBUILD_OFFICIAL_PROVIDER_ID,
|
GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||||
@@ -56,7 +53,7 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 17;
|
pub(crate) const SCHEMA_VERSION: i32 = 16;
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
@@ -200,11 +197,6 @@ impl Database {
|
|||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
};
|
};
|
||||||
db.create_tables()?;
|
db.create_tables()?;
|
||||||
// Keep the test database structurally identical to a fresh production
|
|
||||||
// database. Marking the base DDL as current without running the
|
|
||||||
// migration chain creates a false-current schema and makes restore
|
|
||||||
// tests certify columns that do not actually exist.
|
|
||||||
db.apply_schema_migrations()?;
|
|
||||||
db.ensure_model_pricing_seeded()?;
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
Ok(db)
|
Ok(db)
|
||||||
@@ -301,39 +293,3 @@ impl Database {
|
|||||||
Ok(count == 0)
|
Ok(count == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl Database {
|
|
||||||
/// Test-fixture reconciliation helper. Production code cannot call this:
|
|
||||||
/// provider writes there must choose a typed create or update operation.
|
|
||||||
pub(crate) fn reconcile_provider_fixture(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider: &crate::provider::Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let mut input = crate::provider::ProviderMutationInput {
|
|
||||||
id: provider.id.clone(),
|
|
||||||
name: provider.name.clone(),
|
|
||||||
settings_config: provider.settings_config.clone(),
|
|
||||||
website_url: provider.website_url.clone(),
|
|
||||||
category: provider.category.clone(),
|
|
||||||
created_at: provider.created_at,
|
|
||||||
sort_index: provider.sort_index,
|
|
||||||
notes: provider.notes.clone(),
|
|
||||||
meta: provider.meta.clone(),
|
|
||||||
icon: provider.icon.clone(),
|
|
||||||
icon_color: provider.icon_color.clone(),
|
|
||||||
in_failover_queue: provider.in_failover_queue,
|
|
||||||
};
|
|
||||||
if self.get_provider_aggregate(app_type, &input.id)?.is_some() {
|
|
||||||
if let Some(meta) = input.meta.as_mut() {
|
|
||||||
meta.custom_endpoints.clear();
|
|
||||||
}
|
|
||||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
|
||||||
let row = ProviderRowUpdate::from_input(&input)?;
|
|
||||||
self.update_provider(&key, &row)
|
|
||||||
} else {
|
|
||||||
self.create_provider(NewProviderAggregate::from_input(app_type, input)?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ impl Database {
|
|||||||
app_type TEXT NOT NULL,
|
app_type TEXT NOT NULL,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
added_at INTEGER,
|
added_at INTEGER,
|
||||||
last_used INTEGER,
|
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||||
FOREIGN KEY (provider_id, app_type)
|
|
||||||
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
|
||||||
UNIQUE (provider_id, app_type, url)
|
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -100,7 +97,6 @@ impl Database {
|
|||||||
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||||||
enabled_pi BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||||
content_hash TEXT,
|
content_hash TEXT,
|
||||||
updated_at INTEGER NOT NULL DEFAULT 0
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
@@ -109,36 +105,6 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// Reserve the v17 device-local ledgers here so later stacked features
|
|
||||||
// never mutate the semantics of an already-published migration.
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS pi_provider_projections (
|
|
||||||
provider_id TEXT PRIMARY KEY,
|
|
||||||
provider_key TEXT NOT NULL UNIQUE,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS skill_deployments (
|
|
||||||
app_type TEXT NOT NULL CHECK (app_type = 'pi'),
|
|
||||||
skill_id TEXT NOT NULL,
|
|
||||||
destination TEXT NOT NULL,
|
|
||||||
destination_key TEXT NOT NULL,
|
|
||||||
method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')),
|
|
||||||
source_identity TEXT NOT NULL,
|
|
||||||
deployed_digest TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (app_type, skill_id, destination_key),
|
|
||||||
UNIQUE (app_type, destination_key)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 6. Skill Repos 表
|
// 6. Skill Repos 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
"CREATE TABLE IF NOT EXISTS skill_repos (
|
||||||
@@ -545,13 +511,6 @@ impl Database {
|
|||||||
Self::migrate_v15_to_v16(conn)?;
|
Self::migrate_v15_to_v16(conn)?;
|
||||||
Self::set_user_version(conn, 16)?;
|
Self::set_user_version(conn, 16)?;
|
||||||
}
|
}
|
||||||
16 => {
|
|
||||||
log::info!(
|
|
||||||
"迁移数据库从 v16 到 v17(规范化 provider endpoint 并预留设备本地 ledger)"
|
|
||||||
);
|
|
||||||
Self::migrate_v16_to_v17(conn)?;
|
|
||||||
Self::set_user_version(conn, 17)?;
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||||
@@ -1564,112 +1523,6 @@ impl Database {
|
|||||||
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
|
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v16 -> v17: make endpoint rows a lossless, uniquely owned child
|
|
||||||
/// collection. The device-local ledger DDL is reserved in the same
|
|
||||||
/// migration because later stacked PRs must not rewrite a released
|
|
||||||
/// user_version step.
|
|
||||||
fn migrate_v16_to_v17(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
if Self::table_exists(conn, "provider_endpoints")? {
|
|
||||||
Self::add_column_if_missing(conn, "provider_endpoints", "last_used", "INTEGER")?;
|
|
||||||
conn.execute_batch(
|
|
||||||
"UPDATE provider_endpoints AS kept
|
|
||||||
SET added_at = (
|
|
||||||
SELECT MIN(other.added_at)
|
|
||||||
FROM provider_endpoints AS other
|
|
||||||
WHERE other.provider_id = kept.provider_id
|
|
||||||
AND other.app_type = kept.app_type
|
|
||||||
AND other.url = kept.url
|
|
||||||
),
|
|
||||||
last_used = (
|
|
||||||
SELECT MAX(other.last_used)
|
|
||||||
FROM provider_endpoints AS other
|
|
||||||
WHERE other.provider_id = kept.provider_id
|
|
||||||
AND other.app_type = kept.app_type
|
|
||||||
AND other.url = kept.url
|
|
||||||
)
|
|
||||||
WHERE kept.id = (
|
|
||||||
SELECT MIN(other.id)
|
|
||||||
FROM provider_endpoints AS other
|
|
||||||
WHERE other.provider_id = kept.provider_id
|
|
||||||
AND other.app_type = kept.app_type
|
|
||||||
AND other.url = kept.url
|
|
||||||
);
|
|
||||||
DELETE FROM provider_endpoints
|
|
||||||
WHERE id NOT IN (
|
|
||||||
SELECT MIN(id)
|
|
||||||
FROM provider_endpoints
|
|
||||||
GROUP BY provider_id, app_type, url
|
|
||||||
);
|
|
||||||
DROP TABLE IF EXISTS provider_endpoints_v17_canonical;
|
|
||||||
CREATE TABLE provider_endpoints_v17_canonical (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
added_at INTEGER,
|
|
||||||
last_used INTEGER,
|
|
||||||
FOREIGN KEY (provider_id, app_type)
|
|
||||||
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
|
||||||
UNIQUE (provider_id, app_type, url)
|
|
||||||
);
|
|
||||||
INSERT INTO provider_endpoints_v17_canonical
|
|
||||||
(id, provider_id, app_type, url, added_at, last_used)
|
|
||||||
SELECT id, provider_id, app_type, url, added_at, last_used
|
|
||||||
FROM provider_endpoints;
|
|
||||||
DROP TABLE provider_endpoints;
|
|
||||||
ALTER TABLE provider_endpoints_v17_canonical
|
|
||||||
RENAME TO provider_endpoints;",
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
} else {
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE provider_endpoints (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
added_at INTEGER,
|
|
||||||
last_used INTEGER,
|
|
||||||
FOREIGN KEY (provider_id, app_type)
|
|
||||||
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
|
||||||
UNIQUE (provider_id, app_type, url)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
|
||||||
}
|
|
||||||
if Self::table_exists(conn, "skills")? {
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"skills",
|
|
||||||
"enabled_pi",
|
|
||||||
"BOOLEAN NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE IF NOT EXISTS pi_provider_projections (
|
|
||||||
provider_id TEXT PRIMARY KEY,
|
|
||||||
provider_key TEXT NOT NULL UNIQUE,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS skill_deployments (
|
|
||||||
app_type TEXT NOT NULL CHECK (app_type = 'pi'),
|
|
||||||
skill_id TEXT NOT NULL,
|
|
||||||
destination TEXT NOT NULL,
|
|
||||||
destination_key TEXT NOT NULL,
|
|
||||||
method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')),
|
|
||||||
source_identity TEXT NOT NULL,
|
|
||||||
deployed_digest TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (app_type, skill_id, destination_key),
|
|
||||||
UNIQUE (app_type, destination_key)
|
|
||||||
);",
|
|
||||||
)
|
|
||||||
.map_err(|error| AppError::Database(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 插入默认模型定价数据
|
/// 插入默认模型定价数据
|
||||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
@@ -2384,6 +2237,7 @@ impl Database {
|
|||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
// Qwen 系列 (阿里巴巴)
|
// Qwen 系列 (阿里巴巴)
|
||||||
|
("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"),
|
||||||
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
||||||
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
||||||
(
|
(
|
||||||
@@ -3369,7 +3223,7 @@ mod tests {
|
|||||||
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||||
|
|
||||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
assert_eq!(Database::get_user_version(&conn)?, 16);
|
||||||
let counts: (i64, i64, i64, i64) = conn.query_row(
|
let counts: (i64, i64, i64, i64) = conn.query_row(
|
||||||
"SELECT
|
"SELECT
|
||||||
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
|
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
|
||||||
@@ -3382,67 +3236,4 @@ mod tests {
|
|||||||
assert_eq!(counts, (0, 1, 0, 1));
|
assert_eq!(counts, (0, 1, 0, 1));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migrate_v16_to_v17_preserves_endpoint_metadata_and_starts_ledgers_empty(
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = Connection::open_in_memory()?;
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE providers (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (id, app_type)
|
|
||||||
);
|
|
||||||
CREATE TABLE provider_endpoints (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
added_at INTEGER
|
|
||||||
);
|
|
||||||
CREATE TABLE skills (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
INSERT INTO providers (id, app_type) VALUES ('provider', 'pi');
|
|
||||||
INSERT INTO provider_endpoints
|
|
||||||
(id, provider_id, app_type, url, added_at)
|
|
||||||
VALUES
|
|
||||||
(1, 'provider', 'pi', 'https://duplicate.test', 20),
|
|
||||||
(2, 'provider', 'pi', 'https://duplicate.test', 10);
|
|
||||||
INSERT INTO skills (id, enabled_codex) VALUES ('existing', 1);",
|
|
||||||
)?;
|
|
||||||
Database::set_user_version(&conn, 16)?;
|
|
||||||
|
|
||||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
|
||||||
|
|
||||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
|
||||||
assert!(Database::has_column(
|
|
||||||
&conn,
|
|
||||||
"provider_endpoints",
|
|
||||||
"last_used"
|
|
||||||
)?);
|
|
||||||
assert!(Database::has_column(&conn, "skills", "enabled_pi")?);
|
|
||||||
assert!(Database::table_exists(&conn, "pi_provider_projections")?);
|
|
||||||
assert!(Database::table_exists(&conn, "skill_deployments")?);
|
|
||||||
let endpoint: (i64, Option<i64>) = conn.query_row(
|
|
||||||
"SELECT COUNT(*), MIN(added_at)
|
|
||||||
FROM provider_endpoints
|
|
||||||
WHERE provider_id = 'provider'
|
|
||||||
AND app_type = 'pi'
|
|
||||||
AND url = 'https://duplicate.test'",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)?;
|
|
||||||
assert_eq!(endpoint, (1, Some(10)));
|
|
||||||
let ledgers: (i64, i64) = conn.query_row(
|
|
||||||
"SELECT
|
|
||||||
(SELECT COUNT(*) FROM pi_provider_projections),
|
|
||||||
(SELECT COUNT(*) FROM skill_deployments)",
|
|
||||||
[],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)?;
|
|
||||||
assert_eq!(ledgers, (0, 0));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#![cfg(test)]
|
|
||||||
|
|
||||||
//! 数据库模块测试
|
//! 数据库模块测试
|
||||||
//!
|
//!
|
||||||
//! 包含 Schema 迁移和基本功能的测试。
|
//! 包含 Schema 迁移和基本功能的测试。
|
||||||
|
|||||||
@@ -109,35 +109,27 @@ pub fn import_provider_from_deeplink(
|
|||||||
|
|
||||||
let provider_id = provider.id.clone();
|
let provider_id = provider.id.clone();
|
||||||
|
|
||||||
// All endpoints supplied by one import request belong to the same create
|
// Use ProviderService to add the provider
|
||||||
// intent. Put the non-primary endpoints into the initial aggregate so the
|
ProviderService::add(state, app_type.clone(), provider, true)?;
|
||||||
// provider row and its complete endpoint set commit atomically.
|
|
||||||
let initial_endpoints = &mut provider
|
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||||
.meta
|
for ep in all_endpoints.iter().skip(1) {
|
||||||
.get_or_insert_with(ProviderMeta::default)
|
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||||
.custom_endpoints;
|
|
||||||
for endpoint in all_endpoints.iter().skip(1) {
|
|
||||||
let normalized = endpoint.trim().trim_end_matches('/').to_string();
|
|
||||||
if !normalized.is_empty() {
|
if !normalized.is_empty() {
|
||||||
initial_endpoints.insert(
|
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||||
|
state,
|
||||||
|
app_type.clone(),
|
||||||
|
&provider_id,
|
||||||
normalized.clone(),
|
normalized.clone(),
|
||||||
crate::settings::CustomEndpoint {
|
) {
|
||||||
url: normalized,
|
log::warn!(
|
||||||
added_at: Some(timestamp),
|
"Failed to add custom endpoint '{}': {e}",
|
||||||
last_used: None,
|
crate::url_for_log(&normalized)
|
||||||
},
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderService owns the strict aggregate create.
|
|
||||||
ProviderService::add(
|
|
||||||
state,
|
|
||||||
app_type.clone(),
|
|
||||||
crate::services::provider::provider_to_mutation_input(provider),
|
|
||||||
true,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// If enabled=true, set as current provider
|
// If enabled=true, set as current provider
|
||||||
if merged_request.enabled.unwrap_or(false) {
|
if merged_request.enabled.unwrap_or(false) {
|
||||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
#![cfg(test)]
|
|
||||||
|
|
||||||
//! Deep link module tests
|
//! Deep link module tests
|
||||||
|
|
||||||
use super::mcp::parse_mcp_apps;
|
use super::mcp::parse_mcp_apps;
|
||||||
use super::parser::parse_deeplink_url;
|
use super::parser::parse_deeplink_url;
|
||||||
use super::prompt::import_prompt_from_deeplink;
|
use super::prompt::import_prompt_from_deeplink;
|
||||||
use super::provider::{import_provider_from_deeplink, parse_and_merge_config};
|
use super::provider::parse_and_merge_config;
|
||||||
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
||||||
use super::DeepLinkImportRequest;
|
use super::DeepLinkImportRequest;
|
||||||
use crate::AppType;
|
use crate::AppType;
|
||||||
@@ -954,39 +952,6 @@ fn test_parse_multiple_endpoints_comma_separated() {
|
|||||||
assert!(endpoint.contains("https://api3.example.com"));
|
assert!(endpoint.contains("https://api3.example.com"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn provider_deeplink_creates_all_initial_endpoints_in_one_aggregate() {
|
|
||||||
let _test_home = TestHomeGuard::new();
|
|
||||||
let request = parse_deeplink_url(
|
|
||||||
"ccswitch://v1/import?resource=provider&app=claude&name=Endpoint%20Aggregate&endpoint=https%3A%2F%2Fprimary.example.com,https%3A%2F%2Fsecond.example.com%2F,https%3A%2F%2Fthird.example.com&apiKey=sk-test",
|
|
||||||
)
|
|
||||||
.expect("parse provider deeplink");
|
|
||||||
let state = AppState::new(Arc::new(Database::memory().expect("create memory db")));
|
|
||||||
|
|
||||||
let provider_id =
|
|
||||||
import_provider_from_deeplink(&state, request).expect("import provider aggregate");
|
|
||||||
let aggregate = state
|
|
||||||
.db
|
|
||||||
.get_provider_aggregate(AppType::Claude.as_str(), &provider_id)
|
|
||||||
.expect("read provider aggregate")
|
|
||||||
.expect("provider exists");
|
|
||||||
|
|
||||||
assert_eq!(aggregate.endpoints.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
aggregate.endpoints["https://second.example.com"].url,
|
|
||||||
"https://second.example.com"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
aggregate.endpoints["https://third.example.com"].url,
|
|
||||||
"https://third.example.com"
|
|
||||||
);
|
|
||||||
assert!(aggregate
|
|
||||||
.endpoints
|
|
||||||
.values()
|
|
||||||
.all(|endpoint| endpoint.added_at.is_some() && endpoint.last_used.is_none()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_single_endpoint_backward_compatible() {
|
fn test_parse_single_endpoint_backward_compatible() {
|
||||||
// Old format with single endpoint should still work
|
// Old format with single endpoint should still work
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ pub enum AppError {
|
|||||||
Config(String),
|
Config(String),
|
||||||
#[error("无效输入: {0}")]
|
#[error("无效输入: {0}")]
|
||||||
InvalidInput(String),
|
InvalidInput(String),
|
||||||
#[error("未找到: {0}")]
|
|
||||||
NotFound(String),
|
|
||||||
/// 结构化冲突:并发前置期望失败(如 reconcile 的 ExpectAbsent 撞上竞争
|
|
||||||
/// 创建、ExpectPresent 的指纹过期)。调用方据此重读重试或上浮,不得解析
|
|
||||||
/// Database(String) 文本。由前置工程 A 认证契约引入(T9)。
|
|
||||||
#[error("并发冲突: {0}")]
|
|
||||||
Conflict(String),
|
|
||||||
#[error("IO 错误: {path}: {source}")]
|
#[error("IO 错误: {path}: {source}")]
|
||||||
Io {
|
Io {
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
+4
-11
@@ -45,10 +45,7 @@ pub use codex_config::{
|
|||||||
pub use commands::open_provider_terminal;
|
pub use commands::open_provider_terminal;
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||||
pub use database::{
|
pub use database::{Database, Profile};
|
||||||
Database, NewEndpoint, NewProviderAggregate, Profile, ProviderKey, ProviderRowUpdate,
|
|
||||||
RenameProvider,
|
|
||||||
};
|
|
||||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||||
pub use error::AppError;
|
pub use error::AppError;
|
||||||
pub use grok_config::get_grok_config_path;
|
pub use grok_config::get_grok_config_path;
|
||||||
@@ -60,7 +57,7 @@ pub use mcp::{
|
|||||||
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
|
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
|
||||||
};
|
};
|
||||||
pub use prompt::Prompt;
|
pub use prompt::Prompt;
|
||||||
pub use provider::{Provider, ProviderAggregate, ProviderMeta, ProviderMutationInput};
|
pub use provider::{Provider, ProviderMeta};
|
||||||
pub use services::{
|
pub use services::{
|
||||||
profile::{ProfilePayload, ProfileScope, ProfileService},
|
profile::{ProfilePayload, ProfileScope, ProfileService},
|
||||||
provider::reapply_current_codex_official_live,
|
provider::reapply_current_codex_official_live,
|
||||||
@@ -1991,7 +1988,6 @@ fn initialize_common_config_snippets(state: &store::AppState) {
|
|||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
if should_run_legacy_migration {
|
if should_run_legacy_migration {
|
||||||
let mut legacy_migration_succeeded = true;
|
|
||||||
for app_type in [
|
for app_type in [
|
||||||
crate::app_config::AppType::Claude,
|
crate::app_config::AppType::Claude,
|
||||||
crate::app_config::AppType::Codex,
|
crate::app_config::AppType::Codex,
|
||||||
@@ -2005,14 +2001,11 @@ fn initialize_common_config_snippets(state: &store::AppState) {
|
|||||||
"✗ Failed to migrate legacy common-config usage for {}: {e}",
|
"✗ Failed to migrate legacy common-config usage for {}: {e}",
|
||||||
app_type.as_str()
|
app_type.as_str()
|
||||||
);
|
);
|
||||||
legacy_migration_succeeded = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if legacy_migration_succeeded {
|
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||||
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,84 +43,6 @@ pub struct Provider {
|
|||||||
pub in_failover_queue: bool,
|
pub in_failover_queue: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// IPC/service input for creating or editing a provider.
|
|
||||||
///
|
|
||||||
/// This deliberately is not the hydrated [`Provider`] read projection. In
|
|
||||||
/// particular, callers cannot pass a DAO aggregate back into the provider-row
|
|
||||||
/// writer without first crossing the service boundary, where endpoint
|
|
||||||
/// ownership is checked.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ProviderMutationInput {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
#[serde(rename = "settingsConfig")]
|
|
||||||
pub settings_config: Value,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "websiteUrl")]
|
|
||||||
pub website_url: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub category: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: Option<i64>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "sortIndex")]
|
|
||||||
pub sort_index: Option<usize>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub meta: Option<ProviderMeta>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub icon: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "iconColor")]
|
|
||||||
pub icon_color: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
#[serde(rename = "inFailoverQueue")]
|
|
||||||
pub in_failover_queue: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ProviderMutationInput> for Provider {
|
|
||||||
fn from(input: ProviderMutationInput) -> Self {
|
|
||||||
Self {
|
|
||||||
id: input.id,
|
|
||||||
name: input.name,
|
|
||||||
settings_config: input.settings_config,
|
|
||||||
website_url: input.website_url,
|
|
||||||
category: input.category,
|
|
||||||
created_at: input.created_at,
|
|
||||||
sort_index: input.sort_index,
|
|
||||||
notes: input.notes,
|
|
||||||
meta: input.meta,
|
|
||||||
icon: input.icon,
|
|
||||||
icon_color: input.icon_color,
|
|
||||||
in_failover_queue: input.in_failover_queue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A provider row and every endpoint owned by that row.
|
|
||||||
///
|
|
||||||
/// SQLite stores endpoints separately from provider metadata. This aggregate
|
|
||||||
/// is the only lossless DAO boundary; legacy `Provider` reads are projections
|
|
||||||
/// of it for API compatibility.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ProviderAggregate {
|
|
||||||
pub provider: Provider,
|
|
||||||
#[serde(default)]
|
|
||||||
pub endpoints: IndexMap<String, crate::settings::CustomEndpoint>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderAggregate {
|
|
||||||
pub(crate) fn into_provider(mut self) -> Provider {
|
|
||||||
self.provider
|
|
||||||
.meta
|
|
||||||
.get_or_insert_with(ProviderMeta::default)
|
|
||||||
.custom_endpoints = self.endpoints.into_iter().collect();
|
|
||||||
self.provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
/// 从现有ID创建供应商
|
/// 从现有ID创建供应商
|
||||||
pub fn with_id(
|
pub fn with_id(
|
||||||
@@ -145,72 +67,6 @@ impl Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn row_content_fingerprint(&self) -> String {
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
fn hash_canonical(value: &serde_json::Value, hasher: &mut Sha256) {
|
|
||||||
match value {
|
|
||||||
serde_json::Value::Null => hasher.update(b"n"),
|
|
||||||
serde_json::Value::Bool(value) => {
|
|
||||||
hasher.update(b"b");
|
|
||||||
hasher.update([*value as u8]);
|
|
||||||
}
|
|
||||||
serde_json::Value::Number(value) => {
|
|
||||||
let text = value.to_string();
|
|
||||||
hasher.update(b"#");
|
|
||||||
hasher.update((text.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(text.as_bytes());
|
|
||||||
}
|
|
||||||
serde_json::Value::String(value) => {
|
|
||||||
hasher.update(b"s");
|
|
||||||
hasher.update((value.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(value.as_bytes());
|
|
||||||
}
|
|
||||||
serde_json::Value::Array(items) => {
|
|
||||||
hasher.update(b"[");
|
|
||||||
hasher.update((items.len() as u64).to_le_bytes());
|
|
||||||
for item in items {
|
|
||||||
hash_canonical(item, hasher);
|
|
||||||
}
|
|
||||||
hasher.update(b"]");
|
|
||||||
}
|
|
||||||
serde_json::Value::Object(map) => {
|
|
||||||
hasher.update(b"{");
|
|
||||||
hasher.update((map.len() as u64).to_le_bytes());
|
|
||||||
let mut keys: Vec<&String> = map.keys().collect();
|
|
||||||
keys.sort();
|
|
||||||
for key in keys {
|
|
||||||
hasher.update((key.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(key.as_bytes());
|
|
||||||
hash_canonical(&map[key.as_str()], hasher);
|
|
||||||
}
|
|
||||||
hasher.update(b"}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut meta = serde_json::to_value(&self.meta).unwrap_or(serde_json::Value::Null);
|
|
||||||
if let serde_json::Value::Object(map) = &mut meta {
|
|
||||||
map.remove("custom_endpoints");
|
|
||||||
map.remove("customEndpoints");
|
|
||||||
}
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
for part in [
|
|
||||||
serde_json::Value::String(self.name.clone()),
|
|
||||||
self.settings_config.clone(),
|
|
||||||
serde_json::to_value(&self.website_url).unwrap_or(serde_json::Value::Null),
|
|
||||||
serde_json::to_value(&self.category).unwrap_or(serde_json::Value::Null),
|
|
||||||
serde_json::to_value(&self.notes).unwrap_or(serde_json::Value::Null),
|
|
||||||
serde_json::to_value(&self.icon).unwrap_or(serde_json::Value::Null),
|
|
||||||
serde_json::to_value(&self.icon_color).unwrap_or(serde_json::Value::Null),
|
|
||||||
meta,
|
|
||||||
] {
|
|
||||||
hash_canonical(&part, &mut hasher);
|
|
||||||
hasher.update([0u8]);
|
|
||||||
}
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_codex_oauth(&self) -> bool {
|
pub fn is_codex_oauth(&self) -> bool {
|
||||||
self.provider_type() == Some("codex_oauth")
|
self.provider_type() == Some("codex_oauth")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,10 +348,8 @@ mod tests {
|
|||||||
let provider_b =
|
let provider_b =
|
||||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
.unwrap();
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
|
||||||
.unwrap();
|
|
||||||
db.set_current_provider("claude", "a").unwrap();
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
db.add_to_failover_queue("claude", "b").unwrap();
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
@@ -376,10 +374,8 @@ mod tests {
|
|||||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
provider_b.sort_index = Some(1);
|
provider_b.sort_index = Some(1);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
.unwrap();
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
|
||||||
.unwrap();
|
|
||||||
db.set_current_provider("claude", "a").unwrap();
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
|
||||||
db.add_to_failover_queue("claude", "b").unwrap();
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
@@ -411,10 +407,8 @@ mod tests {
|
|||||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
provider_b.sort_index = Some(1);
|
provider_b.sort_index = Some(1);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
.unwrap();
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
|
||||||
.unwrap();
|
|
||||||
db.set_current_provider("claude", "a").unwrap();
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
|
||||||
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
||||||
@@ -450,10 +444,8 @@ mod tests {
|
|||||||
let provider_b =
|
let provider_b =
|
||||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
.unwrap();
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
db.add_to_failover_queue("claude", "a").unwrap();
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
db.add_to_failover_queue("claude", "b").unwrap();
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
@@ -493,8 +485,7 @@ mod tests {
|
|||||||
|
|
||||||
let provider_a =
|
let provider_a =
|
||||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
.unwrap();
|
|
||||||
db.add_to_failover_queue("claude", "a").unwrap();
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
|
||||||
// 启用自动故障转移
|
// 启用自动故障转移
|
||||||
|
|||||||
@@ -4,32 +4,15 @@
|
|||||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
||||||
|
|
||||||
type PerAppLocks = Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>;
|
|
||||||
|
|
||||||
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
||||||
///
|
///
|
||||||
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Default)]
|
||||||
pub struct SwitchLockManager {
|
pub struct SwitchLockManager {
|
||||||
locks: PerAppLocks,
|
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SwitchLockManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
// Some commands construct a short-lived AppState around the shared
|
|
||||||
// database before running a blocking sync. A per-ProxyService map
|
|
||||||
// would give those paths a different lock and defeat serialization
|
|
||||||
// with provider rename/switch operations in the primary AppState.
|
|
||||||
static LOCKS: OnceLock<PerAppLocks> = OnceLock::new();
|
|
||||||
Self {
|
|
||||||
locks: LOCKS
|
|
||||||
.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
|
|
||||||
.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SwitchLockManager {
|
impl SwitchLockManager {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::config::{atomic_write, write_json_file};
|
use crate::config::{atomic_write, write_json_file};
|
||||||
use crate::database::NewProviderAggregate;
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::opencode_config::get_opencode_dir;
|
use crate::opencode_config::get_opencode_dir;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -289,10 +288,7 @@ impl OmoService {
|
|||||||
in_failover_queue: false,
|
in_failover_queue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.db.create_provider(NewProviderAggregate::from_input(
|
state.db.save_provider("opencode", &provider)?;
|
||||||
"opencode",
|
|
||||||
crate::services::provider::provider_to_mutation_input(provider.clone()),
|
|
||||||
)?)?;
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::database::{NewEndpoint, ProviderKey};
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::settings::CustomEndpoint;
|
use crate::settings::CustomEndpoint;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
@@ -48,10 +47,9 @@ pub fn add_custom_endpoint(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.add_provider_endpoint(&key, NewEndpoint::now(normalized)?)?;
|
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +61,9 @@ pub fn remove_custom_endpoint(
|
|||||||
url: String,
|
url: String,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
state
|
||||||
state.db.remove_provider_endpoint(&key, &normalized)?;
|
.db
|
||||||
|
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +76,17 @@ pub fn update_endpoint_last_used(
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||||
|
|
||||||
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
|
// Get provider, update last_used, save back
|
||||||
state
|
let mut providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
.db
|
if let Some(provider) = providers.get_mut(provider_id) {
|
||||||
.touch_provider_endpoint(&key, &normalized, now_millis())
|
if let Some(meta) = provider.meta.as_mut() {
|
||||||
|
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
|
||||||
|
endpoint.last_used = Some(now_millis());
|
||||||
|
state.db.save_provider(app_type.as_str(), provider)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current timestamp in milliseconds
|
/// Get current timestamp in milliseconds
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ use crate::store::AppState;
|
|||||||
use super::gemini_auth::{
|
use super::gemini_auth::{
|
||||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
|
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::normalize_claude_models_in_value;
|
||||||
normalize_claude_models_in_value, provider_row_fingerprint, provider_to_mutation_input,
|
|
||||||
reconcile_provider_record_with_precondition, ReconcilePrecondition,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
|
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
|
||||||
/// effective budget (openai/codex#31860), far below the 1.05M API spec.
|
/// effective budget (openai/codex#31860), far below the 1.05M API spec.
|
||||||
@@ -1282,12 +1279,6 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
|||||||
// Sync providers based on mode
|
// Sync providers based on mode
|
||||||
for app_type in AppType::all() {
|
for app_type in AppType::all() {
|
||||||
if app_type.is_additive_mode() {
|
if app_type.is_additive_mode() {
|
||||||
// Provider rename and every additive live mutation share this
|
|
||||||
// per-app lock. Acquire it before reading the catalog so a key
|
|
||||||
// cannot be renamed after this sync captured a stale provider map.
|
|
||||||
let _guard = futures::executor::block_on(
|
|
||||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
|
||||||
);
|
|
||||||
// Additive mode: sync ALL providers
|
// Additive mode: sync ALL providers
|
||||||
sync_all_providers_to_live(state, &app_type)?;
|
sync_all_providers_to_live(state, &app_type)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -1573,12 +1564,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
reconcile_provider_record_with_precondition(
|
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||||
&state.db,
|
|
||||||
app_type.as_str(),
|
|
||||||
provider_to_mutation_input(provider.clone()),
|
|
||||||
ReconcilePrecondition::ExpectAbsent,
|
|
||||||
)?;
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||||
@@ -1746,25 +1732,15 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
};
|
};
|
||||||
|
|
||||||
if existing_ids.contains(&id) {
|
if existing_ids.contains(&id) {
|
||||||
match state.db.get_provider_aggregate("opencode", &id) {
|
match state.db.get_provider_by_id(&id, "opencode") {
|
||||||
Ok(Some(existing)) => {
|
Ok(Some(existing)) => {
|
||||||
let existing = existing.provider;
|
|
||||||
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
|
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
|
||||||
if existing.settings_config != settings_config || existing.name != display_name
|
if existing.settings_config != settings_config || existing.name != display_name
|
||||||
{
|
{
|
||||||
let fingerprint = provider_row_fingerprint(&existing);
|
|
||||||
let mut provider = existing;
|
let mut provider = existing;
|
||||||
provider.name = display_name;
|
provider.name = display_name;
|
||||||
provider.settings_config = settings_config;
|
provider.settings_config = settings_config;
|
||||||
if let Some(meta) = provider.meta.as_mut() {
|
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||||
meta.custom_endpoints.clear();
|
|
||||||
}
|
|
||||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
|
||||||
&state.db,
|
|
||||||
"opencode",
|
|
||||||
provider_to_mutation_input(provider),
|
|
||||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
|
||||||
) {
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to update OpenCode provider '{id}' from live config: {e}"
|
"Failed to update OpenCode provider '{id}' from live config: {e}"
|
||||||
);
|
);
|
||||||
@@ -1791,12 +1767,7 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||||
&state.db,
|
|
||||||
"opencode",
|
|
||||||
provider_to_mutation_input(provider),
|
|
||||||
ReconcilePrecondition::ExpectAbsent,
|
|
||||||
) {
|
|
||||||
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
log::warn!("Failed to import OpenCode provider '{id}': {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1846,22 +1817,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
};
|
};
|
||||||
|
|
||||||
if existing_ids.contains(&id) {
|
if existing_ids.contains(&id) {
|
||||||
match state.db.get_provider_aggregate("openclaw", &id) {
|
match state.db.get_provider_by_id(&id, "openclaw") {
|
||||||
Ok(Some(existing)) => {
|
Ok(Some(existing)) => {
|
||||||
let existing = existing.provider;
|
|
||||||
if existing.settings_config != settings_config {
|
if existing.settings_config != settings_config {
|
||||||
let fingerprint = provider_row_fingerprint(&existing);
|
|
||||||
let mut provider = existing;
|
let mut provider = existing;
|
||||||
provider.settings_config = settings_config;
|
provider.settings_config = settings_config;
|
||||||
if let Some(meta) = provider.meta.as_mut() {
|
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||||
meta.custom_endpoints.clear();
|
|
||||||
}
|
|
||||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
|
||||||
&state.db,
|
|
||||||
"openclaw",
|
|
||||||
provider_to_mutation_input(provider),
|
|
||||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
|
||||||
) {
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to update OpenClaw provider '{id}' from live config: {e}"
|
"Failed to update OpenClaw provider '{id}' from live config: {e}"
|
||||||
);
|
);
|
||||||
@@ -1894,12 +1855,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||||
&state.db,
|
|
||||||
"openclaw",
|
|
||||||
provider_to_mutation_input(provider),
|
|
||||||
ReconcilePrecondition::ExpectAbsent,
|
|
||||||
) {
|
|
||||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1936,22 +1892,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing_ids.contains(&name) {
|
if existing_ids.contains(&name) {
|
||||||
match state.db.get_provider_aggregate("hermes", &name) {
|
match state.db.get_provider_by_id(&name, "hermes") {
|
||||||
Ok(Some(existing)) => {
|
Ok(Some(existing)) => {
|
||||||
let existing = existing.provider;
|
|
||||||
if existing.settings_config != config {
|
if existing.settings_config != config {
|
||||||
let fingerprint = provider_row_fingerprint(&existing);
|
|
||||||
let mut provider = existing;
|
let mut provider = existing;
|
||||||
provider.settings_config = config;
|
provider.settings_config = config;
|
||||||
if let Some(meta) = provider.meta.as_mut() {
|
if let Err(e) = state.db.save_provider("hermes", &provider) {
|
||||||
meta.custom_endpoints.clear();
|
|
||||||
}
|
|
||||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
|
||||||
&state.db,
|
|
||||||
"hermes",
|
|
||||||
provider_to_mutation_input(provider),
|
|
||||||
ReconcilePrecondition::ExpectPresent { fingerprint },
|
|
||||||
) {
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to update Hermes provider '{name}' from live config: {e}"
|
"Failed to update Hermes provider '{name}' from live config: {e}"
|
||||||
);
|
);
|
||||||
@@ -1977,12 +1923,7 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = reconcile_provider_record_with_precondition(
|
if let Err(e) = state.db.save_provider("hermes", &provider) {
|
||||||
&state.db,
|
|
||||||
"hermes",
|
|
||||||
provider_to_mutation_input(provider),
|
|
||||||
ReconcilePrecondition::ExpectAbsent,
|
|
||||||
) {
|
|
||||||
log::warn!("Failed to import Hermes provider '{name}': {e}");
|
log::warn!("Failed to import Hermes provider '{name}': {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-1083
File diff suppressed because it is too large
Load Diff
+221
-294
@@ -10,9 +10,7 @@ use crate::proxy::server::ProxyServer;
|
|||||||
use crate::proxy::switch_lock::SwitchLockManager;
|
use crate::proxy::switch_lock::SwitchLockManager;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::services::provider::{
|
use crate::services::provider::{
|
||||||
build_effective_settings_with_common_config, provider_row_fingerprint,
|
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||||
provider_to_mutation_input, reconcile_provider_record_with_precondition,
|
|
||||||
write_live_with_common_config, ReconcilePrecondition,
|
|
||||||
};
|
};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -982,27 +980,6 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_synced_live_token(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
observed_fingerprint: String,
|
|
||||||
mut provider: Provider,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if let Some(meta) = provider.meta.as_mut() {
|
|
||||||
meta.custom_endpoints.clear();
|
|
||||||
}
|
|
||||||
reconcile_provider_record_with_precondition(
|
|
||||||
self.db.as_ref(),
|
|
||||||
app_type,
|
|
||||||
provider_to_mutation_input(provider),
|
|
||||||
ReconcilePrecondition::ExpectPresent {
|
|
||||||
fingerprint: observed_fingerprint,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|error| format!("同步 {app_type}/{provider_id} Live Token 到数据库失败: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sync_live_config_to_provider(
|
async fn sync_live_config_to_provider(
|
||||||
&self,
|
&self,
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
@@ -1015,89 +992,91 @@ impl ProxyService {
|
|||||||
.map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?;
|
.map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
if let Some(provider_id) = provider_id {
|
if let Some(provider_id) = provider_id {
|
||||||
let Some(mut provider) = self
|
if let Ok(Some(mut provider)) =
|
||||||
.db
|
self.db.get_provider_by_id(&provider_id, "claude")
|
||||||
.get_provider_by_id(&provider_id, "claude")
|
{
|
||||||
.map_err(|error| {
|
if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) {
|
||||||
format!("读取 Claude 供应商 '{provider_id}' 失败: {error}")
|
let token_pair = [
|
||||||
})?
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
else {
|
"ANTHROPIC_API_KEY",
|
||||||
return Err(format!("Claude 当前供应商不存在: {provider_id}"));
|
"OPENROUTER_API_KEY",
|
||||||
};
|
"OPENAI_API_KEY",
|
||||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
]
|
||||||
if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) {
|
.into_iter()
|
||||||
let token_pair = [
|
.find_map(|key| {
|
||||||
"ANTHROPIC_AUTH_TOKEN",
|
env.get(key)
|
||||||
"ANTHROPIC_API_KEY",
|
.and_then(|v| v.as_str())
|
||||||
"OPENROUTER_API_KEY",
|
.map(|s| (key, s.trim()))
|
||||||
"OPENAI_API_KEY",
|
})
|
||||||
]
|
.filter(|(_, token)| {
|
||||||
.into_iter()
|
!token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER
|
||||||
.find_map(|key| {
|
});
|
||||||
env.get(key)
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| (key, s.trim()))
|
|
||||||
})
|
|
||||||
.filter(|(_, token)| {
|
|
||||||
!token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some((token_key, token)) = token_pair {
|
if let Some((token_key, token)) = token_pair {
|
||||||
let env_obj = provider
|
let env_obj = provider
|
||||||
.settings_config
|
.settings_config
|
||||||
.get_mut("env")
|
.get_mut("env")
|
||||||
.and_then(|v| v.as_object_mut());
|
.and_then(|v| v.as_object_mut());
|
||||||
|
|
||||||
match env_obj {
|
match env_obj {
|
||||||
Some(obj) => {
|
Some(obj) => {
|
||||||
if token_key == "ANTHROPIC_AUTH_TOKEN"
|
if token_key == "ANTHROPIC_AUTH_TOKEN"
|
||||||
|| token_key == "ANTHROPIC_API_KEY"
|
|| token_key == "ANTHROPIC_API_KEY"
|
||||||
{
|
{
|
||||||
let mut updated = false;
|
let mut updated = false;
|
||||||
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
|
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
|
||||||
obj.insert(
|
obj.insert(
|
||||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||||
json!(token),
|
json!(token),
|
||||||
);
|
);
|
||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
if obj.contains_key("ANTHROPIC_API_KEY") {
|
if obj.contains_key("ANTHROPIC_API_KEY") {
|
||||||
obj.insert(
|
obj.insert(
|
||||||
"ANTHROPIC_API_KEY".to_string(),
|
"ANTHROPIC_API_KEY".to_string(),
|
||||||
json!(token),
|
json!(token),
|
||||||
);
|
);
|
||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
if !updated {
|
if !updated {
|
||||||
|
obj.insert(token_key.to_string(), json!(token));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
obj.insert(token_key.to_string(), json!(token));
|
obj.insert(token_key.to_string(), json!(token));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
obj.insert(token_key.to_string(), json!(token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// 至少写入一份可用的 Token
|
|
||||||
if provider.settings_config.is_null() {
|
|
||||||
provider.settings_config = json!({});
|
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
|
// 至少写入一份可用的 Token
|
||||||
|
if provider.settings_config.is_null() {
|
||||||
|
provider.settings_config = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
if let Some(root) = provider.settings_config.as_object_mut()
|
||||||
root.insert("env".to_string(), json!({ token_key: token }));
|
{
|
||||||
} else {
|
root.insert(
|
||||||
log::warn!(
|
"env".to_string(),
|
||||||
|
json!({ token_key: token }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.persist_synced_live_token(
|
if let Err(e) = self.db.update_provider_settings_config(
|
||||||
"claude",
|
"claude",
|
||||||
&provider_id,
|
&provider_id,
|
||||||
observed_fingerprint,
|
&provider.settings_config,
|
||||||
provider,
|
) {
|
||||||
)?;
|
log::warn!("同步 Claude Token 到数据库失败: {e}");
|
||||||
log::info!("已同步 Claude Token 到数据库 (provider: {provider_id})");
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"已同步 Claude Token 到数据库 (provider: {provider_id})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1108,56 +1087,55 @@ impl ProxyService {
|
|||||||
.map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?;
|
.map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
if let Some(provider_id) = provider_id {
|
if let Some(provider_id) = provider_id {
|
||||||
let Some(mut provider) = self
|
if let Ok(Some(mut provider)) =
|
||||||
.db
|
self.db.get_provider_by_id(&provider_id, "codex")
|
||||||
.get_provider_by_id(&provider_id, "codex")
|
|
||||||
.map_err(|error| {
|
|
||||||
format!("读取 Codex 供应商 '{provider_id}' 失败: {error}")
|
|
||||||
})?
|
|
||||||
else {
|
|
||||||
return Err(format!("Codex 当前供应商不存在: {provider_id}"));
|
|
||||||
};
|
|
||||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
|
||||||
// The built-in official row is a routing capability, not
|
|
||||||
// a credential store. Its auth must remain empty even
|
|
||||||
// when the live Codex login uses OPENAI_API_KEY mode.
|
|
||||||
if crate::proxy::providers::is_codex_official_provider(&provider) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Some(token) = live_config
|
|
||||||
.get("auth")
|
|
||||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
|
||||||
{
|
{
|
||||||
if let Some(auth_obj) = provider
|
// The built-in official row is a routing capability, not
|
||||||
.settings_config
|
// a credential store. Its auth must remain empty even
|
||||||
.get_mut("auth")
|
// when the live Codex login uses OPENAI_API_KEY mode.
|
||||||
.and_then(|v| v.as_object_mut())
|
if crate::proxy::providers::is_codex_official_provider(&provider) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(token) = live_config
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||||
{
|
{
|
||||||
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
if let Some(auth_obj) = provider
|
||||||
} else {
|
.settings_config
|
||||||
if provider.settings_config.is_null() {
|
.get_mut("auth")
|
||||||
provider.settings_config = json!({});
|
.and_then(|v| v.as_object_mut())
|
||||||
}
|
{
|
||||||
|
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
||||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
|
||||||
root.insert("auth".to_string(), json!({ "OPENAI_API_KEY": token }));
|
|
||||||
} else {
|
} else {
|
||||||
log::warn!(
|
if provider.settings_config.is_null() {
|
||||||
|
provider.settings_config = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||||
|
root.insert(
|
||||||
|
"auth".to_string(),
|
||||||
|
json!({ "OPENAI_API_KEY": token }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.db.update_provider_settings_config(
|
||||||
|
"codex",
|
||||||
|
&provider_id,
|
||||||
|
&provider.settings_config,
|
||||||
|
) {
|
||||||
|
log::warn!("同步 Codex Token 到数据库失败: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.persist_synced_live_token(
|
|
||||||
"codex",
|
|
||||||
&provider_id,
|
|
||||||
observed_fingerprint,
|
|
||||||
provider,
|
|
||||||
)?;
|
|
||||||
log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1167,50 +1145,51 @@ impl ProxyService {
|
|||||||
.map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?;
|
.map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
if let Some(provider_id) = provider_id {
|
if let Some(provider_id) = provider_id {
|
||||||
let Some(mut provider) = self
|
if let Ok(Some(mut provider)) =
|
||||||
.db
|
self.db.get_provider_by_id(&provider_id, "gemini")
|
||||||
.get_provider_by_id(&provider_id, "gemini")
|
|
||||||
.map_err(|error| {
|
|
||||||
format!("读取 Gemini 供应商 '{provider_id}' 失败: {error}")
|
|
||||||
})?
|
|
||||||
else {
|
|
||||||
return Err(format!("Gemini 当前供应商不存在: {provider_id}"));
|
|
||||||
};
|
|
||||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
|
||||||
if let Some(token) = live_config
|
|
||||||
.get("env")
|
|
||||||
.and_then(|v| v.get("GEMINI_API_KEY"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
|
||||||
{
|
{
|
||||||
if let Some(env_obj) = provider
|
if let Some(token) = live_config
|
||||||
.settings_config
|
.get("env")
|
||||||
.get_mut("env")
|
.and_then(|v| v.get("GEMINI_API_KEY"))
|
||||||
.and_then(|v| v.as_object_mut())
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)
|
||||||
{
|
{
|
||||||
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
if let Some(env_obj) = provider
|
||||||
} else {
|
.settings_config
|
||||||
if provider.settings_config.is_null() {
|
.get_mut("env")
|
||||||
provider.settings_config = json!({});
|
.and_then(|v| v.as_object_mut())
|
||||||
}
|
{
|
||||||
|
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
||||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
|
||||||
root.insert("env".to_string(), json!({ "GEMINI_API_KEY": token }));
|
|
||||||
} else {
|
} else {
|
||||||
log::warn!(
|
if provider.settings_config.is_null() {
|
||||||
|
provider.settings_config = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||||
|
root.insert(
|
||||||
|
"env".to_string(),
|
||||||
|
json!({ "GEMINI_API_KEY": token }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.db.update_provider_settings_config(
|
||||||
|
"gemini",
|
||||||
|
&provider_id,
|
||||||
|
&provider.settings_config,
|
||||||
|
) {
|
||||||
|
log::warn!("同步 Gemini Token 到数据库失败: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"已同步 Gemini Token 到数据库 (provider: {provider_id})"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.persist_synced_live_token(
|
|
||||||
"gemini",
|
|
||||||
&provider_id,
|
|
||||||
observed_fingerprint,
|
|
||||||
provider,
|
|
||||||
)?;
|
|
||||||
log::info!("已同步 Gemini Token 到数据库 (provider: {provider_id})");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1220,41 +1199,38 @@ impl ProxyService {
|
|||||||
.map_err(|e| format!("获取 Grok Build 当前供应商失败: {e}"))?;
|
.map_err(|e| format!("获取 Grok Build 当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
if let Some(provider_id) = provider_id {
|
if let Some(provider_id) = provider_id {
|
||||||
let Some(mut provider) = self
|
if let Ok(Some(mut provider)) =
|
||||||
.db
|
self.db.get_provider_by_id(&provider_id, "grokbuild")
|
||||||
.get_provider_by_id(&provider_id, "grokbuild")
|
|
||||||
.map_err(|error| {
|
|
||||||
format!("读取 Grok Build 供应商 '{provider_id}' 失败: {error}")
|
|
||||||
})?
|
|
||||||
else {
|
|
||||||
return Err(format!("Grok Build 当前供应商不存在: {provider_id}"));
|
|
||||||
};
|
|
||||||
let observed_fingerprint = provider_row_fingerprint(&provider);
|
|
||||||
let live_config_toml = live_config
|
|
||||||
.get("config")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if let Some(token) =
|
|
||||||
crate::grok_config::extract_inline_api_key(live_config_toml)
|
|
||||||
{
|
{
|
||||||
if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER {
|
let live_config_toml = live_config
|
||||||
if let Some(provider_config) = provider
|
.get("config")
|
||||||
.settings_config
|
.and_then(Value::as_str)
|
||||||
.get("config")
|
.unwrap_or_default();
|
||||||
.and_then(Value::as_str)
|
if let Some(token) =
|
||||||
{
|
crate::grok_config::extract_inline_api_key(live_config_toml)
|
||||||
let updated =
|
{
|
||||||
crate::grok_config::update_api_key(provider_config, &token)
|
if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER {
|
||||||
|
if let Some(provider_config) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
{
|
||||||
|
let updated =
|
||||||
|
crate::grok_config::update_api_key(provider_config, &token)
|
||||||
|
.map_err(|e| {
|
||||||
|
format!("更新 Grok Build API Key 失败: {e}")
|
||||||
|
})?;
|
||||||
|
provider.settings_config["config"] = json!(updated);
|
||||||
|
self.db
|
||||||
|
.update_provider_settings_config(
|
||||||
|
"grokbuild",
|
||||||
|
&provider_id,
|
||||||
|
&provider.settings_config,
|
||||||
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
format!("更新 Grok Build API Key 失败: {e}")
|
format!("同步 Grok Build Token 到数据库失败: {e}")
|
||||||
})?;
|
})?;
|
||||||
provider.settings_config["config"] = json!(updated);
|
}
|
||||||
self.persist_synced_live_token(
|
|
||||||
"grokbuild",
|
|
||||||
&provider_id,
|
|
||||||
observed_fingerprint,
|
|
||||||
provider,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3837,7 +3813,7 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("claude", &provider)
|
db.save_provider("claude", &provider)
|
||||||
.expect("save provider");
|
.expect("save provider");
|
||||||
db.set_current_provider("claude", "p1")
|
db.set_current_provider("claude", "p1")
|
||||||
.expect("set db current provider");
|
.expect("set db current provider");
|
||||||
@@ -4023,7 +3999,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("cn_official".to_string());
|
provider.category = Some("cn_official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider)
|
||||||
.expect("save DeepSeek provider");
|
.expect("save DeepSeek provider");
|
||||||
db.set_current_provider("codex", "deepseek")
|
db.set_current_provider("codex", "deepseek")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -4109,7 +4085,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider)
|
||||||
.expect("save misclassified DeepSeek provider");
|
.expect("save misclassified DeepSeek provider");
|
||||||
db.set_current_provider("codex", "deepseek")
|
db.set_current_provider("codex", "deepseek")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -4170,7 +4146,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &official)
|
db.save_provider("codex", &official)
|
||||||
.expect("save official provider");
|
.expect("save official provider");
|
||||||
|
|
||||||
let mut third_party = Provider::with_id(
|
let mut third_party = Provider::with_id(
|
||||||
@@ -4189,7 +4165,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
third_party.category = Some("custom".to_string());
|
third_party.category = Some("custom".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &third_party)
|
db.save_provider("codex", &third_party)
|
||||||
.expect("save third-party provider");
|
.expect("save third-party provider");
|
||||||
db.set_current_provider("codex", "codex-official")
|
db.set_current_provider("codex", "codex-official")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -4338,8 +4314,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
official.category = Some("official".to_string());
|
official.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &official)
|
db.save_provider("codex", &official).expect("save official");
|
||||||
.expect("save official");
|
|
||||||
db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID)
|
db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID)
|
||||||
.expect("set current");
|
.expect("set current");
|
||||||
crate::settings::set_current_provider(
|
crate::settings::set_current_provider(
|
||||||
@@ -4417,7 +4392,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider)
|
||||||
.expect("save misclassified DeepSeek provider");
|
.expect("save misclassified DeepSeek provider");
|
||||||
db.set_current_provider("codex", "deepseek")
|
db.set_current_provider("codex", "deepseek")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -4497,7 +4472,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider)
|
||||||
.expect("save misclassified DeepSeek provider");
|
.expect("save misclassified DeepSeek provider");
|
||||||
db.set_current_provider("codex", "deepseek")
|
db.set_current_provider("codex", "deepseek")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -4609,7 +4584,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider)
|
||||||
.expect("save misclassified DeepSeek provider");
|
.expect("save misclassified DeepSeek provider");
|
||||||
db.set_current_provider("codex", "deepseek")
|
db.set_current_provider("codex", "deepseek")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -4727,7 +4702,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("official".to_string());
|
provider.category = Some("official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider)
|
||||||
.expect("save misclassified DeepSeek provider");
|
.expect("save misclassified DeepSeek provider");
|
||||||
db.set_current_provider("codex", "deepseek")
|
db.set_current_provider("codex", "deepseek")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -4863,7 +4838,7 @@ wire_api = "responses"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.category = Some("cn_official".to_string());
|
provider.category = Some("cn_official".to_string());
|
||||||
db.reconcile_provider_fixture("codex", &provider)
|
db.save_provider("codex", &provider)
|
||||||
.expect("save DeepSeek provider");
|
.expect("save DeepSeek provider");
|
||||||
db.set_current_provider("codex", "deepseek")
|
db.set_current_provider("codex", "deepseek")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -5340,7 +5315,7 @@ model = "gpt-5.1-codex"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("claude", &provider)
|
db.save_provider("claude", &provider)
|
||||||
.expect("save provider");
|
.expect("save provider");
|
||||||
db.set_current_provider("claude", "p1")
|
db.set_current_provider("claude", "p1")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -5396,7 +5371,7 @@ model = "gpt-5.1-codex"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("claude", &provider)
|
db.save_provider("claude", &provider)
|
||||||
.expect("save provider");
|
.expect("save provider");
|
||||||
db.set_current_provider("claude", "p1")
|
db.set_current_provider("claude", "p1")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -5432,54 +5407,6 @@ model = "gpt-5.1-codex"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn synced_live_token_cannot_revert_a_concurrent_provider_edit() {
|
|
||||||
let db = Arc::new(Database::memory().expect("init db"));
|
|
||||||
let service = ProxyService::new(db.clone());
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"p1".to_string(),
|
|
||||||
"Original".to_string(),
|
|
||||||
json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "stale" } }),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
db.reconcile_provider_fixture("claude", &provider)
|
|
||||||
.expect("save provider");
|
|
||||||
|
|
||||||
let observed = db
|
|
||||||
.get_provider_by_id("p1", "claude")
|
|
||||||
.expect("read observed provider")
|
|
||||||
.expect("provider exists");
|
|
||||||
let fingerprint = provider_row_fingerprint(&observed);
|
|
||||||
let mut token_update = observed.clone();
|
|
||||||
token_update.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] = json!("from-live");
|
|
||||||
|
|
||||||
let mut concurrent_edit = observed;
|
|
||||||
concurrent_edit.name = "Concurrent user edit".to_string();
|
|
||||||
if let Some(meta) = concurrent_edit.meta.as_mut() {
|
|
||||||
meta.custom_endpoints.clear();
|
|
||||||
}
|
|
||||||
let input = provider_to_mutation_input(concurrent_edit);
|
|
||||||
let key = crate::database::ProviderKey::new("claude", "p1").expect("provider key");
|
|
||||||
let row = crate::database::ProviderRowUpdate::from_input(&input).expect("row update");
|
|
||||||
db.update_provider(&key, &row)
|
|
||||||
.expect("persist concurrent user edit");
|
|
||||||
|
|
||||||
let error = service
|
|
||||||
.persist_synced_live_token("claude", "p1", fingerprint, token_update)
|
|
||||||
.expect_err("stale token sync must fail closed");
|
|
||||||
assert!(error.contains("changed since it was read"), "{error}");
|
|
||||||
|
|
||||||
let saved = db
|
|
||||||
.get_provider_by_id("p1", "claude")
|
|
||||||
.expect("read saved provider")
|
|
||||||
.expect("saved provider exists");
|
|
||||||
assert_eq!(saved.name, "Concurrent user edit");
|
|
||||||
assert_eq!(
|
|
||||||
saved.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"],
|
|
||||||
json!("stale")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn switch_proxy_target_updates_live_backup_when_taken_over() {
|
async fn switch_proxy_target_updates_live_backup_when_taken_over() {
|
||||||
@@ -5509,9 +5436,9 @@ model = "gpt-5.1-codex"
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
db.save_provider("claude", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("claude", "a")
|
db.set_current_provider("claude", "a")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -5584,9 +5511,9 @@ model = "gpt-5.1-codex"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
db.save_provider("claude", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("claude", "a")
|
db.set_current_provider("claude", "a")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -5735,11 +5662,11 @@ model = "gpt-5.1-codex"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
db.save_provider("claude", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.reconcile_provider_fixture("claude", &provider_c)
|
db.save_provider("claude", &provider_c)
|
||||||
.expect("save provider c");
|
.expect("save provider c");
|
||||||
db.set_current_provider("claude", "a")
|
db.set_current_provider("claude", "a")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -5822,9 +5749,9 @@ model = "gpt-5.1-codex"
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("claude", &provider_a)
|
db.save_provider("claude", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("claude", &provider_b)
|
db.save_provider("claude", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("claude", "a")
|
db.set_current_provider("claude", "a")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -6122,9 +6049,9 @@ requires_openai_auth = true
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider_a)
|
db.save_provider("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("codex", &provider_b)
|
db.save_provider("codex", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("codex", "a")
|
db.set_current_provider("codex", "a")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -6297,9 +6224,9 @@ requires_openai_auth = true
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider_a)
|
db.save_provider("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("codex", &provider_b)
|
db.save_provider("codex", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("codex", "a")
|
db.set_current_provider("codex", "a")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -6541,9 +6468,9 @@ requires_openai_auth = true
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider_a)
|
db.save_provider("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("codex", &provider_b)
|
db.save_provider("codex", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("codex", "a")
|
db.set_current_provider("codex", "a")
|
||||||
.expect("set current provider a");
|
.expect("set current provider a");
|
||||||
@@ -6677,9 +6604,9 @@ requires_openai_auth = true
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.reconcile_provider_fixture("codex", &provider_a)
|
db.save_provider("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("codex", &provider_b)
|
db.save_provider("codex", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("codex", "a")
|
db.set_current_provider("codex", "a")
|
||||||
.expect("set current provider a");
|
.expect("set current provider a");
|
||||||
@@ -6759,9 +6686,9 @@ requires_openai_auth = true
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("codex", &provider_a)
|
db.save_provider("codex", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("codex", &provider_b)
|
db.save_provider("codex", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("codex", "a")
|
db.set_current_provider("codex", "a")
|
||||||
.expect("set current provider a");
|
.expect("set current provider a");
|
||||||
@@ -7043,7 +6970,7 @@ requires_openai_auth = true
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("claude", &provider)
|
db.save_provider("claude", &provider)
|
||||||
.expect("save provider");
|
.expect("save provider");
|
||||||
db.set_current_provider("claude", "p1")
|
db.set_current_provider("claude", "p1")
|
||||||
.expect("set current provider");
|
.expect("set current provider");
|
||||||
@@ -7297,9 +7224,9 @@ experimental_bearer_token = "PROXY_MANAGED"
|
|||||||
grok_provider_config("https://b.example.com/v1", "b-key"),
|
grok_provider_config("https://b.example.com/v1", "b-key"),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("grokbuild", &provider_a)
|
db.save_provider("grokbuild", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("grokbuild", &provider_b)
|
db.save_provider("grokbuild", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("grokbuild", "grok-a")
|
db.set_current_provider("grokbuild", "grok-a")
|
||||||
.expect("set db current");
|
.expect("set db current");
|
||||||
@@ -7364,9 +7291,9 @@ experimental_bearer_token = "PROXY_MANAGED"
|
|||||||
json!({ "config": "not valid toml = [" }),
|
json!({ "config": "not valid toml = [" }),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
db.reconcile_provider_fixture("grokbuild", &provider_a)
|
db.save_provider("grokbuild", &provider_a)
|
||||||
.expect("save provider a");
|
.expect("save provider a");
|
||||||
db.reconcile_provider_fixture("grokbuild", &provider_b)
|
db.save_provider("grokbuild", &provider_b)
|
||||||
.expect("save provider b");
|
.expect("save provider b");
|
||||||
db.set_current_provider("grokbuild", "grok-a")
|
db.set_current_provider("grokbuild", "grok-a")
|
||||||
.expect("set db current");
|
.expect("set db current");
|
||||||
|
|||||||
@@ -2374,31 +2374,38 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
||||||
///
|
///
|
||||||
/// 兼容三种情况:
|
/// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序:
|
||||||
/// 1. `skills/foo` 这类直接相对路径;
|
/// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先;
|
||||||
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
|
/// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
|
||||||
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。
|
/// 3. 兜底:仓库根本身含 `SKILL.md`。
|
||||||
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
|
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
|
||||||
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
|
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
|
||||||
|
let install_name = source_rel
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())?;
|
||||||
|
|
||||||
|
// 1. 直接相对路径命中(明确路径优先)——必须校验 SKILL.md,否则同名空壳目录
|
||||||
|
// (如 ast-grep/agent-skill 根下的 plugin 包目录 ast-grep/)会被误判为源目录。
|
||||||
let direct = root.join(&source_rel);
|
let direct = root.join(&source_rel);
|
||||||
if direct.is_dir() {
|
if direct.is_dir() && direct.join("SKILL.md").is_file() {
|
||||||
return Some(direct);
|
return Some(direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
|
// 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md)
|
||||||
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
|
if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill directory '{}' not found at direct path, using fallback: {}",
|
"Skill directory '{}' not found at direct path, using fallback: {}",
|
||||||
target_name,
|
install_name,
|
||||||
found.display()
|
found.display()
|
||||||
);
|
);
|
||||||
return Some(found);
|
return Some(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
if root.is_dir() && root.join("SKILL.md").exists() {
|
// 3. 兜底:仓库根本身是 skill
|
||||||
|
if root.join("SKILL.md").is_file() {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
|
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
|
||||||
target_name,
|
install_name,
|
||||||
);
|
);
|
||||||
return Some(root.to_path_buf());
|
return Some(root.to_path_buf());
|
||||||
}
|
}
|
||||||
@@ -4452,4 +4459,74 @@ mod tests {
|
|||||||
"existing destination skill should be preserved"
|
"existing destination skill should be preserved"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_skill_source_dir_rejects_same_name_wrapper_without_skill_md() {
|
||||||
|
// 复刻 issue #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, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CustomEndpoint {
|
pub struct CustomEndpoint {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub added_at: Option<i64>,
|
pub added_at: i64,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub last_used: Option<i64>,
|
pub last_used: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ use std::fs;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{
|
||||||
AppType, InstalledSkill, McpServer, McpService, NewProviderAggregate, ProfilePayload,
|
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService,
|
||||||
ProfileScope, ProfileService, Prompt, PromptService, Provider, ProviderService, SkillApps,
|
Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService,
|
||||||
SkillService,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
use support::{create_test_state, ensure_test_home, new_provider_input, reset_test_fs, test_mutex};
|
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
fn claude_provider(id: &str, token: &str) -> Provider {
|
fn claude_provider(id: &str, token: &str) -> Provider {
|
||||||
Provider::with_id(
|
Provider::with_id(
|
||||||
@@ -108,41 +107,34 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
|||||||
let state = create_test_state().expect("create test state");
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
|
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
|
||||||
ProviderService::add(
|
state
|
||||||
&state,
|
.db
|
||||||
AppType::Claude,
|
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||||
new_provider_input(claude_provider("p1", "key-1")),
|
.expect("save provider p1");
|
||||||
false,
|
state
|
||||||
)
|
.db
|
||||||
.expect("create provider p1");
|
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
||||||
ProviderService::add(
|
.expect("save provider p2");
|
||||||
&state,
|
|
||||||
AppType::Claude,
|
|
||||||
new_provider_input(claude_provider("p2", "key-2")),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("create provider p2");
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||||
.expect("set current provider p1");
|
.expect("set current provider p1");
|
||||||
|
|
||||||
// Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用)
|
// Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用)
|
||||||
for provider in [
|
state
|
||||||
desktop_provider("d1", "dk-1"),
|
.db
|
||||||
desktop_provider("d2", "dk-2"),
|
.save_provider(
|
||||||
] {
|
AppType::ClaudeDesktop.as_str(),
|
||||||
state
|
&desktop_provider("d1", "dk-1"),
|
||||||
.db
|
)
|
||||||
.create_provider(
|
.expect("save desktop provider d1");
|
||||||
NewProviderAggregate::from_input(
|
state
|
||||||
AppType::ClaudeDesktop.as_str(),
|
.db
|
||||||
new_provider_input(provider),
|
.save_provider(
|
||||||
)
|
AppType::ClaudeDesktop.as_str(),
|
||||||
.expect("build typed desktop create"),
|
&desktop_provider("d2", "dk-2"),
|
||||||
)
|
)
|
||||||
.expect("create desktop provider");
|
.expect("save desktop provider d2");
|
||||||
}
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||||||
@@ -295,13 +287,10 @@ fn shared_profile_sides_are_isolated_and_mergeable() {
|
|||||||
let state = create_test_state().expect("create test state");
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
// 种子:Claude 侧有当前供应商 + 启用的 MCP
|
// 种子:Claude 侧有当前供应商 + 启用的 MCP
|
||||||
ProviderService::add(
|
state
|
||||||
&state,
|
.db
|
||||||
AppType::Claude,
|
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||||
new_provider_input(claude_provider("p1", "key-1")),
|
.expect("save provider p1");
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("create provider p1");
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||||
@@ -507,20 +496,14 @@ fn switching_profile_autosaves_previous_profile_state() {
|
|||||||
let state = create_test_state().expect("create test state");
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
// ---- 种子:Claude 侧两套供应商 / MCP / Prompt ----
|
// ---- 种子:Claude 侧两套供应商 / MCP / Prompt ----
|
||||||
ProviderService::add(
|
state
|
||||||
&state,
|
.db
|
||||||
AppType::Claude,
|
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||||
new_provider_input(claude_provider("p1", "key-1")),
|
.expect("save provider p1");
|
||||||
false,
|
state
|
||||||
)
|
.db
|
||||||
.expect("create provider p1");
|
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
||||||
ProviderService::add(
|
.expect("save provider p2");
|
||||||
&state,
|
|
||||||
AppType::Claude,
|
|
||||||
new_provider_input(claude_provider("p2", "key-2")),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("create provider p2");
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||||
@@ -682,13 +665,17 @@ fn profile_switch_auto_disables_takeover_before_apply() {
|
|||||||
// ---- 两个 Claude 供应商:custom1 与 custom2 ----
|
// ---- 两个 Claude 供应商:custom1 与 custom2 ----
|
||||||
let mut custom1 = claude_provider("custom1", "custom-key-1");
|
let mut custom1 = claude_provider("custom1", "custom-key-1");
|
||||||
custom1.category = Some("custom".to_string());
|
custom1.category = Some("custom".to_string());
|
||||||
ProviderService::add(&state, AppType::Claude, new_provider_input(custom1), false)
|
state
|
||||||
.expect("create custom1 provider");
|
.db
|
||||||
|
.save_provider(AppType::Claude.as_str(), &custom1)
|
||||||
|
.expect("save custom1 provider");
|
||||||
|
|
||||||
let mut custom2 = claude_provider("custom2", "custom-key-2");
|
let mut custom2 = claude_provider("custom2", "custom-key-2");
|
||||||
custom2.category = Some("custom".to_string());
|
custom2.category = Some("custom".to_string());
|
||||||
ProviderService::add(&state, AppType::Claude, new_provider_input(custom2), false)
|
state
|
||||||
.expect("create custom2 provider");
|
.db
|
||||||
|
.save_provider(AppType::Claude.as_str(), &custom2)
|
||||||
|
.expect("save custom2 provider");
|
||||||
|
|
||||||
// 初始状态:custom1 + 代理接管
|
// 初始状态:custom1 + 代理接管
|
||||||
ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1");
|
ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1");
|
||||||
@@ -770,20 +757,20 @@ fn claude_desktop_profile_scope_is_independent() {
|
|||||||
|
|
||||||
let state = create_test_state().expect("create test state");
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
ProviderService::add(
|
state
|
||||||
&state,
|
.db
|
||||||
AppType::ClaudeDesktop,
|
.save_provider(
|
||||||
new_provider_input(desktop_provider("d1", "dk-1")),
|
AppType::ClaudeDesktop.as_str(),
|
||||||
false,
|
&desktop_provider("d1", "dk-1"),
|
||||||
)
|
)
|
||||||
.expect("create desktop provider d1");
|
.expect("save desktop provider d1");
|
||||||
ProviderService::add(
|
state
|
||||||
&state,
|
.db
|
||||||
AppType::ClaudeDesktop,
|
.save_provider(
|
||||||
new_provider_input(desktop_provider("d2", "dk-2")),
|
AppType::ClaudeDesktop.as_str(),
|
||||||
false,
|
&desktop_provider("d2", "dk-2"),
|
||||||
)
|
)
|
||||||
.expect("create desktop provider d2");
|
.expect("save desktop provider d2");
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ mod support;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use support::{
|
use support::{
|
||||||
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
||||||
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
|
ensure_test_home, reset_test_fs, test_mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn settings_path(home: &Path) -> PathBuf {
|
fn settings_path(home: &Path) -> PathBuf {
|
||||||
@@ -64,18 +64,18 @@ fn grokbuild_import_and_switch_write_live_config() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
|
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
|
||||||
ProviderService::add(
|
state
|
||||||
&state,
|
.db
|
||||||
AppType::GrokBuild,
|
.save_provider(
|
||||||
new_provider_input(Provider::with_id(
|
AppType::GrokBuild.as_str(),
|
||||||
"relay".to_string(),
|
&Provider::with_id(
|
||||||
"Relay".to_string(),
|
"relay".to_string(),
|
||||||
json!({ "config": next_config }),
|
"Relay".to_string(),
|
||||||
None,
|
json!({ "config": next_config }),
|
||||||
)),
|
None,
|
||||||
false,
|
),
|
||||||
)
|
)
|
||||||
.expect("create second Grok Build provider");
|
.expect("save second Grok Build provider");
|
||||||
|
|
||||||
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
|
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
|
||||||
.expect("switch Grok Build provider");
|
.expect("switch Grok Build provider");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use cc_switch_lib::{
|
|||||||
mod support;
|
mod support;
|
||||||
use support::{
|
use support::{
|
||||||
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
||||||
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
|
ensure_test_home, reset_test_fs, test_mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn sanitize_provider_name(name: &str) -> String {
|
fn sanitize_provider_name(name: &str) -> String {
|
||||||
@@ -3084,8 +3084,10 @@ fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_ba
|
|||||||
taken_over_live.clone(),
|
taken_over_live.clone(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
ProviderService::add(&state, AppType::Claude, new_provider_input(provider), false)
|
state
|
||||||
.expect("create placeholder provider");
|
.db
|
||||||
|
.save_provider(AppType::Claude.as_str(), &provider)
|
||||||
|
.expect("save placeholder provider");
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(AppType::Claude.as_str(), "default")
|
.set_current_provider(AppType::Claude.as_str(), "default")
|
||||||
|
|||||||
@@ -1,31 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
|
||||||
update_settings, AppSettings, AppState, Database, MultiAppConfig, Provider,
|
|
||||||
ProviderMutationInput,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Build the public write DTO explicitly for integration tests. Keeping this
|
|
||||||
/// conversion test-only avoids reintroducing a production `From<Provider>`
|
|
||||||
/// path from hydrated read projections to provider mutations.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn new_provider_input(provider: Provider) -> ProviderMutationInput {
|
|
||||||
ProviderMutationInput {
|
|
||||||
id: provider.id,
|
|
||||||
name: provider.name,
|
|
||||||
settings_config: provider.settings_config,
|
|
||||||
website_url: provider.website_url,
|
|
||||||
category: provider.category,
|
|
||||||
created_at: provider.created_at,
|
|
||||||
sort_index: provider.sort_index,
|
|
||||||
notes: provider.notes,
|
|
||||||
meta: provider.meta,
|
|
||||||
icon: provider.icon,
|
|
||||||
icon_color: provider.icon_color,
|
|
||||||
in_failover_queue: provider.in_failover_queue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
||||||
pub fn ensure_test_home() -> &'static Path {
|
pub fn ensure_test_home() -> &'static Path {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription";
|
||||||
|
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
||||||
|
|
||||||
|
interface CodexOauthAccountQuotaProps {
|
||||||
|
/** cc-switch 自管的 ChatGPT 账号 ID */
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 → 认证中心里,单个 ChatGPT (Codex OAuth) 账号的用量展示。
|
||||||
|
*
|
||||||
|
* 直接按 accountId 查询 cc-switch 自管 OAuth token 的订阅额度,复用
|
||||||
|
* `SubscriptionQuotaView` 的展开布局(进度条 + 重置倒计时 + 刷新按钮),
|
||||||
|
* 因此与供应商卡片里的额度展示保持完全一致的观感与状态处理。
|
||||||
|
*
|
||||||
|
* 面板打开时拉取一次,不轮询;用户可点卡片内的刷新按钮手动更新。
|
||||||
|
*/
|
||||||
|
const CodexOauthAccountQuota: React.FC<CodexOauthAccountQuotaProps> = ({
|
||||||
|
accountId,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
data: quota,
|
||||||
|
isFetching: loading,
|
||||||
|
refetch,
|
||||||
|
} = useCodexOauthQuotaByAccountId(accountId, {
|
||||||
|
enabled: true,
|
||||||
|
autoQuery: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。
|
||||||
|
// 用量请求是异步的(Tauri invoke + React Query),加载期间给一个
|
||||||
|
// 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位,
|
||||||
|
// 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。
|
||||||
|
if (loading && !quota) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubscriptionQuotaView
|
||||||
|
quota={quota}
|
||||||
|
loading={loading}
|
||||||
|
refetch={refetch}
|
||||||
|
appIdForExpiredHint="codex_oauth"
|
||||||
|
inline={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CodexOauthAccountQuota;
|
||||||
@@ -24,9 +24,12 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||||
import { copyText } from "@/lib/clipboard";
|
import { copyText } from "@/lib/clipboard";
|
||||||
|
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
|
||||||
|
|
||||||
interface CodexOAuthSectionProps {
|
interface CodexOAuthSectionProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 是否展示每个账号的订阅额度 */
|
||||||
|
showAccountQuota?: boolean;
|
||||||
/** 当前选中的 ChatGPT 账号 ID */
|
/** 当前选中的 ChatGPT 账号 ID */
|
||||||
selectedAccountId?: string | null;
|
selectedAccountId?: string | null;
|
||||||
/** 账号选择回调 */
|
/** 账号选择回调 */
|
||||||
@@ -45,6 +48,7 @@ interface CodexOAuthSectionProps {
|
|||||||
*/
|
*/
|
||||||
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||||
className,
|
className,
|
||||||
|
showAccountQuota = false,
|
||||||
selectedAccountId,
|
selectedAccountId,
|
||||||
onAccountSelect,
|
onAccountSelect,
|
||||||
fastModeEnabled = false,
|
fastModeEnabled = false,
|
||||||
@@ -178,47 +182,52 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
|||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
className="space-y-2 p-2 rounded-md border bg-muted/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<User className="h-5 w-5 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{account.login}</span>
|
<User className="h-5 w-5 text-muted-foreground" />
|
||||||
{defaultAccountId === account.id && (
|
<span className="text-sm font-medium">{account.login}</span>
|
||||||
<Badge variant="secondary" className="text-xs">
|
{defaultAccountId === account.id && (
|
||||||
{t("codexOauth.defaultAccount", "默认")}
|
<Badge variant="secondary" className="text-xs">
|
||||||
</Badge>
|
{t("codexOauth.defaultAccount", "默认")}
|
||||||
)}
|
</Badge>
|
||||||
{selectedAccountId === account.id && (
|
)}
|
||||||
<Badge variant="outline" className="text-xs">
|
{selectedAccountId === account.id && (
|
||||||
{t("codexOauth.selected", "已选中")}
|
<Badge variant="outline" className="text-xs">
|
||||||
</Badge>
|
{t("codexOauth.selected", "已选中")}
|
||||||
)}
|
</Badge>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
{defaultAccountId !== account.id && (
|
<div className="flex items-center gap-1">
|
||||||
|
{defaultAccountId !== account.id && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => setDefaultAccount(account.id)}
|
||||||
|
disabled={isSettingDefaultAccount}
|
||||||
|
>
|
||||||
|
{t("codexOauth.setAsDefault", "设为默认")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="h-7 px-2 text-xs text-muted-foreground"
|
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||||
onClick={() => setDefaultAccount(account.id)}
|
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||||
disabled={isSettingDefaultAccount}
|
disabled={isRemovingAccount}
|
||||||
|
title={t("codexOauth.removeAccount", "移除账号")}
|
||||||
>
|
>
|
||||||
{t("codexOauth.setAsDefault", "设为默认")}
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
|
||||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
|
||||||
disabled={isRemovingAccount}
|
|
||||||
title={t("codexOauth.removeAccount", "移除账号")}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{showAccountQuota && (
|
||||||
|
<CodexOauthAccountQuota accountId={account.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1537,16 +1537,8 @@ function ProviderFormFull({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaSource = payload.meta ?? initialData?.meta;
|
const baseMeta: ProviderMeta | undefined =
|
||||||
const baseMeta: ProviderMeta | undefined = metaSource
|
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||||
? { ...metaSource }
|
|
||||||
: undefined;
|
|
||||||
// Existing-provider edits never own endpoint membership. The backend
|
|
||||||
// rejects endpoint-bearing update payloads; add/remove/touch use their
|
|
||||||
// dedicated commands and remain safe from stale form snapshots.
|
|
||||||
if (isEditMode && baseMeta) {
|
|
||||||
delete baseMeta.custom_endpoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
||||||
const providerType = presetProviderType || initialData?.meta?.providerType;
|
const providerType = presetProviderType || initialData?.meta?.providerType;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CodexOAuthSection />
|
<CodexOAuthSection showAccountQuota />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
supportsOfficialProxyTakeover,
|
supportsOfficialProxyTakeover,
|
||||||
} from "@/utils/providerCapabilities";
|
} from "@/utils/providerCapabilities";
|
||||||
import { isOAuthProviderType } from "@/config/constants";
|
import { isOAuthProviderType } from "@/config/constants";
|
||||||
import { toProviderUpdateInput } from "@/lib/api/providers";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing provider actions (add, update, delete, switch)
|
* Hook for managing provider actions (add, update, delete, switch)
|
||||||
@@ -363,10 +362,7 @@ export function useProviderActions(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await providersApi.update(
|
await providersApi.update(updatedProvider, activeApp);
|
||||||
toProviderUpdateInput(updatedProvider),
|
|
||||||
activeApp,
|
|
||||||
);
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["providers", activeApp],
|
queryKey: ["providers", activeApp],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { Provider } from "@/types";
|
|
||||||
import { toProviderUpdateInput } from "./providers";
|
|
||||||
|
|
||||||
describe("toProviderUpdateInput", () => {
|
|
||||||
it("removes hydrated endpoints and row-state fields from update payloads", () => {
|
|
||||||
const hydrated: Provider = {
|
|
||||||
id: "endpoint-provider",
|
|
||||||
name: "Endpoint provider",
|
|
||||||
settingsConfig: { env: { API_KEY: "secret" } },
|
|
||||||
createdAt: 1_700_000_000,
|
|
||||||
sortIndex: 7,
|
|
||||||
inFailoverQueue: true,
|
|
||||||
meta: {
|
|
||||||
custom_endpoints: {
|
|
||||||
"https://one.example": {
|
|
||||||
url: "https://one.example",
|
|
||||||
addedAt: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
usage_script: {
|
|
||||||
enabled: true,
|
|
||||||
language: "javascript",
|
|
||||||
code: "{}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const update = toProviderUpdateInput(hydrated);
|
|
||||||
|
|
||||||
expect(update).not.toHaveProperty("createdAt");
|
|
||||||
expect(update).not.toHaveProperty("sortIndex");
|
|
||||||
expect(update).not.toHaveProperty("inFailoverQueue");
|
|
||||||
expect(update.meta).not.toHaveProperty("custom_endpoints");
|
|
||||||
expect(update.meta?.usage_script).toEqual(hydrated.meta?.usage_script);
|
|
||||||
expect(
|
|
||||||
hydrated.meta?.custom_endpoints?.["https://one.example"].addedAt,
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import type {
|
import type {
|
||||||
Provider,
|
Provider,
|
||||||
ProviderMeta,
|
|
||||||
UniversalProvider,
|
UniversalProvider,
|
||||||
UniversalProvidersMap,
|
UniversalProvidersMap,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
@@ -13,39 +12,6 @@ export interface ProviderSortUpdate {
|
|||||||
sortIndex: number;
|
sortIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderUpdateMeta = Omit<ProviderMeta, "custom_endpoints"> & {
|
|
||||||
custom_endpoints?: never;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProviderUpdateInput = Omit<
|
|
||||||
Provider,
|
|
||||||
"createdAt" | "sortIndex" | "inFailoverQueue" | "meta"
|
|
||||||
> & {
|
|
||||||
meta?: ProviderUpdateMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function toProviderUpdateInput(provider: Provider): ProviderUpdateInput {
|
|
||||||
let meta: ProviderUpdateMeta | undefined;
|
|
||||||
if (provider.meta) {
|
|
||||||
const rowMeta = { ...provider.meta };
|
|
||||||
delete rowMeta.custom_endpoints;
|
|
||||||
meta = rowMeta as ProviderUpdateMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: provider.id,
|
|
||||||
name: provider.name,
|
|
||||||
settingsConfig: provider.settingsConfig,
|
|
||||||
websiteUrl: provider.websiteUrl,
|
|
||||||
category: provider.category,
|
|
||||||
notes: provider.notes,
|
|
||||||
isPartner: provider.isPartner,
|
|
||||||
meta,
|
|
||||||
icon: provider.icon,
|
|
||||||
iconColor: provider.iconColor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderSwitchEvent {
|
export interface ProviderSwitchEvent {
|
||||||
appType: AppId;
|
appType: AppId;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -98,7 +64,7 @@ export const providersApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
provider: ProviderUpdateInput,
|
provider: Provider,
|
||||||
appId: AppId,
|
appId: AppId,
|
||||||
originalId?: string,
|
originalId?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
|
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
|
||||||
import type { DeleteSessionOptions } from "@/lib/api/sessions";
|
import type { DeleteSessionOptions } from "@/lib/api/sessions";
|
||||||
import { toProviderUpdateInput, type SwitchResult } from "@/lib/api/providers";
|
import type { SwitchResult } from "@/lib/api/providers";
|
||||||
import type { Provider, SessionMeta, Settings } from "@/types";
|
import type { Provider, SessionMeta, Settings } from "@/types";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { generateUUID } from "@/utils/uuid";
|
import { generateUUID } from "@/utils/uuid";
|
||||||
@@ -168,11 +168,7 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
originalId?: string;
|
originalId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await providersApi.update(
|
await providersApi.update(provider, appId, originalId);
|
||||||
toProviderUpdateInput(provider),
|
|
||||||
appId,
|
|
||||||
originalId,
|
|
||||||
);
|
|
||||||
return provider;
|
return provider;
|
||||||
},
|
},
|
||||||
onSuccess: async (provider, variables) => {
|
onSuccess: async (provider, variables) => {
|
||||||
|
|||||||
@@ -112,20 +112,18 @@ export interface UseCodexOauthQuotaOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
* Codex OAuth 订阅额度查询 hook(按账号 ID)
|
||||||
*
|
*
|
||||||
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
* 直接以 cc-switch 自管的 ChatGPT 账号 ID 查询额度,供认证中心里逐个账号
|
||||||
* 而不是 Codex CLI 的 ~/.codex/auth.json。
|
* 展示用量时复用。Query key 与 `useCodexOauthQuota` 一致,绑定到同一账号的
|
||||||
*
|
* 供应商卡片与账号列表会自动去重共享同一份请求缓存。
|
||||||
* Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。
|
|
||||||
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
||||||
*/
|
*/
|
||||||
export function useCodexOauthQuota(
|
export function useCodexOauthQuotaByAccountId(
|
||||||
meta: ProviderMeta | undefined,
|
accountId: string | null,
|
||||||
options: UseCodexOauthQuotaOptions = {},
|
options: UseCodexOauthQuotaOptions = {},
|
||||||
) {
|
) {
|
||||||
const { enabled = true, autoQuery = false } = options;
|
const { enabled = true, autoQuery = false } = options;
|
||||||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
||||||
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
||||||
@@ -140,6 +138,21 @@ export function useCodexOauthQuota(
|
|||||||
return useQuotaKeepLastGood(query, accountId ?? "default");
|
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
||||||
|
*
|
||||||
|
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
||||||
|
* 而不是 Codex CLI 的 ~/.codex/auth.json。账号 ID 从供应商 meta 的
|
||||||
|
* authBinding 中解析,再委托给 `useCodexOauthQuotaByAccountId`。
|
||||||
|
*/
|
||||||
|
export function useCodexOauthQuota(
|
||||||
|
meta: ProviderMeta | undefined,
|
||||||
|
options: UseCodexOauthQuotaOptions = {},
|
||||||
|
) {
|
||||||
|
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
||||||
|
return useCodexOauthQuotaByAccountId(accountId, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* xAI OAuth (SuperGrok 反代) 订阅额度查询 hook
|
* xAI OAuth (SuperGrok 反代) 订阅额度查询 hook
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ export interface AppConfig {
|
|||||||
// 自定义端点配置
|
// 自定义端点配置
|
||||||
export interface CustomEndpoint {
|
export interface CustomEndpoint {
|
||||||
url: string;
|
url: string;
|
||||||
addedAt: number | null;
|
addedAt: number;
|
||||||
lastUsed?: number;
|
lastUsed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
|
||||||
|
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
useCodexOauth: vi.fn(),
|
||||||
|
renderAccountQuota: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
|
||||||
|
useCodexOauth: mocks.useCodexOauth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/CodexOauthAccountQuota", () => ({
|
||||||
|
default: ({ accountId }: { accountId: string }) => {
|
||||||
|
mocks.renderAccountQuota(accountId);
|
||||||
|
return <div data-testid="account-quota">{accountId}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
||||||
|
CopilotAuthSection: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
|
||||||
|
XaiOAuthSection: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CodexOAuthSection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.useCodexOauth.mockReturnValue({
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: "account-1",
|
||||||
|
provider: "codex_oauth",
|
||||||
|
login: "user@example.com",
|
||||||
|
avatar_url: null,
|
||||||
|
authenticated_at: 0,
|
||||||
|
is_default: true,
|
||||||
|
github_domain: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultAccountId: "account-1",
|
||||||
|
hasAnyAccount: true,
|
||||||
|
pollingState: "idle",
|
||||||
|
deviceCode: null,
|
||||||
|
error: null,
|
||||||
|
isPolling: false,
|
||||||
|
isAddingAccount: false,
|
||||||
|
isRemovingAccount: false,
|
||||||
|
isSettingDefaultAccount: false,
|
||||||
|
addAccount: vi.fn(),
|
||||||
|
removeAccount: vi.fn(),
|
||||||
|
setDefaultAccount: vi.fn(),
|
||||||
|
cancelAuth: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render account quota by default", () => {
|
||||||
|
render(<CodexOAuthSection />);
|
||||||
|
|
||||||
|
expect(mocks.renderAccountQuota).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders account quota in Auth Center", () => {
|
||||||
|
render(<AuthCenterPanel />);
|
||||||
|
|
||||||
|
expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1");
|
||||||
|
expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"manifestVersion": 1,
|
|
||||||
"codeAuthority": "src-tauri/src/database/dao/provider_write.rs",
|
|
||||||
"types": {
|
|
||||||
"ProviderKey": ["app_type", "id"],
|
|
||||||
"ProviderRowCreate": ["content", "created_at"],
|
|
||||||
"ProviderRowUpdate": [
|
|
||||||
"name",
|
|
||||||
"settings_config",
|
|
||||||
"website_url",
|
|
||||||
"category",
|
|
||||||
"notes",
|
|
||||||
"meta",
|
|
||||||
"icon",
|
|
||||||
"icon_color"
|
|
||||||
],
|
|
||||||
"NewEndpoint": ["url", "added_at", "last_used"],
|
|
||||||
"NewProviderAggregate": [
|
|
||||||
"key",
|
|
||||||
"row",
|
|
||||||
"sort_index",
|
|
||||||
"in_failover_queue",
|
|
||||||
"initial_endpoints"
|
|
||||||
],
|
|
||||||
"RenameProvider": ["source", "target_id", "row"]
|
|
||||||
},
|
|
||||||
"databaseMethods": {
|
|
||||||
"create_provider": ["NewProviderAggregate"],
|
|
||||||
"update_provider": ["ProviderKey", "ProviderRowUpdate"],
|
|
||||||
"rename_db_only_additive_provider": ["RenameProvider"],
|
|
||||||
"add_provider_endpoint": ["ProviderKey", "NewEndpoint"],
|
|
||||||
"remove_provider_endpoint": ["ProviderKey", "str"],
|
|
||||||
"touch_provider_endpoint": ["ProviderKey", "str", "i64"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user