diff --git a/apps/src-tauri/src/commands/apikey.rs b/apps/src-tauri/src/commands/apikey.rs index 94ce312d6..e138252a4 100644 --- a/apps/src-tauri/src/commands/apikey.rs +++ b/apps/src-tauri/src/commands/apikey.rs @@ -210,10 +210,9 @@ pub async fn service_model_source_mapping_save( #[tauri::command] pub async fn service_model_source_mapping_delete( addr: Option, - id: String, + payload: serde_json::Value, ) -> Result { - let params = serde_json::json!({ "id": id }); - rpc_call_in_background("apikey/modelSourceMappingDelete", addr, Some(params)).await + rpc_call_in_background("apikey/modelSourceMappingDelete", addr, Some(payload)).await } /// 函数 `service_apikey_usage_stats` diff --git a/apps/src/app/models/page.tsx b/apps/src/app/models/page.tsx index 7e173a983..1413e3c7e 100644 --- a/apps/src/app/models/page.tsx +++ b/apps/src/app/models/page.tsx @@ -1087,7 +1087,14 @@ export default function ModelsPage() { size="icon" aria-label={t("删除映射")} disabled={isRoutingSaving} - onClick={() => void deleteSourceMapping(mapping.id)} + onClick={() => + void deleteSourceMapping( + mapping.id, + mapping.sourceKind, + mapping.sourceId, + mapping.upstreamModel, + ) + } > diff --git a/apps/src/hooks/useManagedModels.ts b/apps/src/hooks/useManagedModels.ts index 1ae158f20..28c6f011e 100644 --- a/apps/src/hooks/useManagedModels.ts +++ b/apps/src/hooks/useManagedModels.ts @@ -352,8 +352,12 @@ export function useManagedModels() { }); const sourceMappingDeleteMutation = useMutation({ - mutationFn: (mappingId: string) => - accountClient.deleteManagedModelSourceMapping(mappingId), + mutationFn: (params: { + id: string; + sourceKind: string; + sourceId: string; + upstreamModel: string; + }) => accountClient.deleteManagedModelSourceMapping(params), onSuccess: async () => { await reloadManagedRouting(); await invalidateAll(); @@ -433,9 +437,19 @@ export function useManagedModels() { if (!ensureServiceReady("保存模型映射")) return null; return sourceMappingMutation.mutateAsync(params); }, - deleteSourceMapping: async (mappingId: string) => { + deleteSourceMapping: async ( + mappingId: string, + sourceKind: string, + sourceId: string, + upstreamModel: string, + ) => { if (!ensureServiceReady("删除模型映射")) return false; - await sourceMappingDeleteMutation.mutateAsync(mappingId); + await sourceMappingDeleteMutation.mutateAsync({ + id: mappingId, + sourceKind, + sourceId, + upstreamModel, + }); return true; }, isRefreshing: refreshMutation.isPending, diff --git a/apps/src/lib/api/account-client.ts b/apps/src/lib/api/account-client.ts index 5b887d7cf..2187412b8 100644 --- a/apps/src/lib/api/account-client.ts +++ b/apps/src/lib/api/account-client.ts @@ -838,8 +838,13 @@ export const accountClient = { if (!item) throw new Error("模型映射保存结果为空"); return item; }, - deleteManagedModelSourceMapping: (id: string) => - invoke("service_model_source_mapping_delete", withAddr({ id })), + deleteManagedModelSourceMapping: (params: { + id: string; + sourceKind: string; + sourceId: string; + upstreamModel: string; + }) => + invoke("service_model_source_mapping_delete", withAddr({ payload: params })), async saveManagedModel(params: ManagedModelPayload): Promise { const payload = { previousSlug: params.previousSlug || null, diff --git a/apps/src/lib/api/transport-web-commands.ts b/apps/src/lib/api/transport-web-commands.ts index 1ab922cb0..deab3f0c5 100644 --- a/apps/src/lib/api/transport-web-commands.ts +++ b/apps/src/lib/api/transport-web-commands.ts @@ -543,6 +543,7 @@ export function createWebCommandMap( }, service_model_source_mapping_delete: { rpcMethod: "apikey/modelSourceMappingDelete", + mapParams: (params) => asRecord(asRecord(params)?.payload) ?? {}, }, service_apikey_read_secret: { rpcMethod: "apikey/readSecret", diff --git a/crates/core/migrations/065_model_source_mapping_preferences.sql b/crates/core/migrations/065_model_source_mapping_preferences.sql new file mode 100644 index 000000000..db7cbe37f --- /dev/null +++ b/crates/core/migrations/065_model_source_mapping_preferences.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS model_source_mapping_preferences ( + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + upstream_model TEXT NOT NULL, + preference TEXT NOT NULL CHECK (preference IN ('unlinked', 'disabled')), + updated_at INTEGER NOT NULL, + PRIMARY KEY (source_kind, source_id, upstream_model) +); + +CREATE INDEX IF NOT EXISTS idx_model_source_mapping_preferences_source + ON model_source_mapping_preferences(source_kind, source_id); diff --git a/crates/core/src/storage/accounts.rs b/crates/core/src/storage/accounts.rs index 791b1f892..b274e44ed 100644 --- a/crates/core/src/storage/accounts.rs +++ b/crates/core/src/storage/accounts.rs @@ -488,6 +488,11 @@ impl Storage { WHERE source_kind = 'openai_account' AND source_id = ?1", [account_id], )?; + tx.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = 'openai_account' AND source_id = ?1", + [account_id], + )?; tx.execute("DELETE FROM accounts WHERE id = ?1", [account_id])?; tx.commit()?; Ok(()) diff --git a/crates/core/src/storage/aggregate_apis.rs b/crates/core/src/storage/aggregate_apis.rs index 2ba7dba14..640bc0244 100644 --- a/crates/core/src/storage/aggregate_apis.rs +++ b/crates/core/src/storage/aggregate_apis.rs @@ -351,16 +351,32 @@ impl Storage { /// # 返回 /// 返回函数执行结果 pub fn delete_aggregate_api(&self, api_id: &str) -> Result<()> { - self.conn.execute( + let tx = self.conn.unchecked_transaction()?; + tx.execute( "DELETE FROM aggregate_api_balance_secrets WHERE aggregate_api_id = ?1", [api_id], )?; - self.conn.execute( + tx.execute( "DELETE FROM aggregate_api_secrets WHERE aggregate_api_id = ?1", [api_id], )?; - self.conn - .execute("DELETE FROM aggregate_apis WHERE id = ?1", [api_id])?; + tx.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = 'aggregate_api' AND source_id = ?1", + [api_id], + )?; + tx.execute( + "DELETE FROM model_source_mappings + WHERE source_kind = 'aggregate_api' AND source_id = ?1", + [api_id], + )?; + tx.execute( + "DELETE FROM model_source_models + WHERE source_kind = 'aggregate_api' AND source_id = ?1", + [api_id], + )?; + tx.execute("DELETE FROM aggregate_apis WHERE id = ?1", [api_id])?; + tx.commit()?; Ok(()) } diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index 431512f1e..36a725ae3 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -95,6 +95,15 @@ pub struct ModelSourceMapping { pub updated_at: i64, } +#[derive(Debug, Clone)] +pub struct ModelSourceMappingPreference { + pub source_kind: String, + pub source_id: String, + pub upstream_model: String, + pub preference: String, + pub updated_at: i64, +} + #[derive(Debug, Clone)] pub struct AccountQuotaCapacityTemplate { pub plan_type: String, @@ -1007,6 +1016,11 @@ impl Storage { "064_drop_gateway_error_logs", include_str!("../../migrations/064_drop_gateway_error_logs.sql"), )?; + self.apply_sql_or_compat_migration( + "065_model_source_mapping_preferences", + include_str!("../../migrations/065_model_source_mapping_preferences.sql"), + |s| s.ensure_model_source_tables(), + )?; self.ensure_api_key_rotation_columns()?; self.ensure_aggregate_apis_table()?; self.ensure_aggregate_api_supplier_model_tables()?; diff --git a/crates/core/src/storage/model_sources.rs b/crates/core/src/storage/model_sources.rs index 58432a031..9db7de486 100644 --- a/crates/core/src/storage/model_sources.rs +++ b/crates/core/src/storage/model_sources.rs @@ -1,4 +1,4 @@ -use super::{now_ts, ModelSourceMapping, ModelSourceModel, Storage}; +use super::{now_ts, ModelSourceMapping, ModelSourceMappingPreference, ModelSourceModel, Storage}; use rusqlite::{params, OptionalExtension, Result, Row}; fn map_source_model(row: &Row<'_>) -> Result { @@ -74,7 +74,17 @@ impl Storage { CREATE INDEX IF NOT EXISTS idx_model_source_mappings_platform ON model_source_mappings(platform_model_slug, enabled, priority DESC); CREATE INDEX IF NOT EXISTS idx_model_source_mappings_source - ON model_source_mappings(source_kind, source_id, enabled);", + ON model_source_mappings(source_kind, source_id, enabled); + CREATE TABLE IF NOT EXISTS model_source_mapping_preferences ( + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + upstream_model TEXT NOT NULL, + preference TEXT NOT NULL CHECK (preference IN ('unlinked', 'disabled')), + updated_at INTEGER NOT NULL, + PRIMARY KEY (source_kind, source_id, upstream_model) + ); + CREATE INDEX IF NOT EXISTS idx_model_source_mapping_preferences_source + ON model_source_mapping_preferences(source_kind, source_id);", ) } @@ -229,6 +239,13 @@ impl Storage { AND discovery_kind = ?4", params![&source_kind, &source_id, &upstream_model, &discovery_kind], )?; + self.conn.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = ?1 + AND source_id = ?2 + AND upstream_model = ?3", + params![&source_kind, &source_id, &upstream_model], + )?; } Ok(out) } @@ -340,6 +357,38 @@ impl Storage { Ok(()) } + pub fn delete_model_source_mapping_with_unlink_preference( + &self, + id: &str, + source_kind: &str, + source_id: &str, + upstream_model: &str, + ) -> Result<()> { + let id = normalize_text(id); + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + let upstream_model = normalize_text(upstream_model); + if source_kind.is_empty() || source_id.is_empty() || upstream_model.is_empty() { + return Ok(()); + } + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO model_source_mapping_preferences + (source_kind, source_id, upstream_model, preference, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(source_kind, source_id, upstream_model) DO UPDATE SET + preference = excluded.preference, + updated_at = excluded.updated_at", + params![&source_kind, &source_id, &upstream_model, "unlinked", now_ts()], + )?; + tx.execute( + "DELETE FROM model_source_mappings WHERE id = ?1", + params![&id], + )?; + tx.commit()?; + Ok(()) + } + pub fn delete_model_source_mapping(&self, id: &str) -> Result<()> { self.conn.execute( "DELETE FROM model_source_mappings WHERE id = ?1", @@ -368,4 +417,100 @@ impl Storage { )?; Ok(()) } + + pub fn upsert_model_source_mapping_preference( + &self, + source_kind: &str, + source_id: &str, + upstream_model: &str, + preference: &str, + ) -> Result<()> { + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + let upstream_model = normalize_text(upstream_model); + if source_kind.is_empty() || source_id.is_empty() || upstream_model.is_empty() { + return Ok(()); + } + self.conn.execute( + "INSERT INTO model_source_mapping_preferences + (source_kind, source_id, upstream_model, preference, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(source_kind, source_id, upstream_model) DO UPDATE SET + preference = excluded.preference, + updated_at = excluded.updated_at", + params![ + &source_kind, + &source_id, + &upstream_model, + normalize_text(preference), + now_ts(), + ], + )?; + Ok(()) + } + + pub fn delete_model_source_mapping_preference( + &self, + source_kind: &str, + source_id: &str, + upstream_model: &str, + ) -> Result<()> { + self.conn.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = ?1 AND source_id = ?2 AND upstream_model = ?3", + params![ + normalize_text(source_kind), + normalize_text(source_id), + normalize_text(upstream_model), + ], + )?; + Ok(()) + } + + pub fn delete_model_source_mapping_preferences_for_source( + &self, + source_kind: &str, + source_id: &str, + ) -> Result<()> { + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + if source_kind.is_empty() || source_id.is_empty() { + return Ok(()); + } + self.conn.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = ?1 AND source_id = ?2", + params![&source_kind, &source_id], + )?; + Ok(()) + } + + pub fn list_model_source_mapping_preferences( + &self, + source_kind: &str, + source_id: &str, + ) -> Result> { + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + if source_kind.is_empty() || source_id.is_empty() { + return Ok(Vec::new()); + } + let mut stmt = self.conn.prepare( + "SELECT source_kind, source_id, upstream_model, preference, updated_at + FROM model_source_mapping_preferences + WHERE source_kind = ?1 AND source_id = ?2", + )?; + let rows = stmt.query_map(params![&source_kind, &source_id], map_preference)?; + rows.collect() + } +} + +fn map_preference(row: &Row<'_>) -> Result { + Ok(ModelSourceMappingPreference { + source_kind: row.get(0)?, + source_id: row.get(1)?, + upstream_model: row.get(2)?, + preference: row.get(3)?, + updated_at: row.get(4)?, + }) } diff --git a/crates/core/tests/storage/migration_tests.rs b/crates/core/tests/storage/migration_tests.rs index e83b0f6a1..b170b731d 100644 --- a/crates/core/tests/storage/migration_tests.rs +++ b/crates/core/tests/storage/migration_tests.rs @@ -1180,7 +1180,7 @@ fn observability_storage_compaction_migration_rolls_up_and_prunes_legacy_rows() "DELETE FROM schema_migrations WHERE version = '062_observability_storage_compaction'", [], ) - .expect("remove 057 marker"); + .expect("remove 062 marker"); storage .conn diff --git a/crates/service/src/apikey/apikey_models.rs b/crates/service/src/apikey/apikey_models.rs index c7bffb181..a8039d11b 100644 --- a/crates/service/src/apikey/apikey_models.rs +++ b/crates/service/src/apikey/apikey_models.rs @@ -405,15 +405,41 @@ pub(crate) fn save_managed_model_source_mapping( storage .upsert_model_source_mapping(&mapping) .map_err(|err| format!("save model mapping failed: {err}"))?; + if mapping.enabled { + storage + .delete_model_source_mapping_preference( + &mapping.source_kind, + &mapping.source_id, + &mapping.upstream_model, + ) + .map_err(|err| format!("clear preference failed: {err}"))?; + } else { + storage + .upsert_model_source_mapping_preference( + &mapping.source_kind, + &mapping.source_id, + &mapping.upstream_model, + "disabled", + ) + .map_err(|err| format!("save disable preference failed: {err}"))?; + } Ok(source_mapping_entry(mapping)) } -pub(crate) fn delete_managed_model_source_mapping(id: &str) -> Result<(), String> { +pub(crate) fn delete_managed_model_source_mapping( + id: &str, + source_kind: &str, + source_id: &str, + upstream_model: &str, +) -> Result<(), String> { let storage = storage_helpers::open_storage().ok_or_else(|| "storage unavailable".to_string())?; let id = normalize_required("id", id)?; + let source_kind = normalize_routing_source_kind(source_kind)?; + let source_id = normalize_required("sourceId", source_id)?; + let upstream_model = normalize_required("upstreamModel", upstream_model)?; storage - .delete_model_source_mapping(id.as_str()) + .delete_model_source_mapping_with_unlink_preference(&id, &source_kind, &source_id, &upstream_model) .map_err(|err| format!("delete model mapping failed: {err}")) } @@ -502,6 +528,12 @@ fn sync_openai_account_source_models_with_options( .collect::>(); if let Some(source_id) = requested_source_id.as_deref() { if !active_source_ids.contains(source_id) { + storage + .delete_model_source_mapping_preferences_for_source( + ROUTING_SOURCE_KIND_OPENAI_ACCOUNT, + source_id, + ) + .map_err(|err| format!("delete account preferences failed: {err}"))?; storage .delete_model_source_routes_for_source( ROUTING_SOURCE_KIND_OPENAI_ACCOUNT, @@ -608,6 +640,12 @@ where ROUTING_SOURCE_KIND_AGGREGATE_API, source_id, )?; + storage + .delete_model_source_mapping_preferences_for_source( + ROUTING_SOURCE_KIND_AGGREGATE_API, + source_id, + ) + .map_err(|err| format!("delete api preferences failed: {err}"))?; storage .delete_model_source_routes_for_source(ROUTING_SOURCE_KIND_AGGREGATE_API, source_id) .map_err(|err| format!("delete stale aggregate api source routes failed: {err}"))?; @@ -832,6 +870,13 @@ fn auto_associate_source_models( .map(|mapping| mapping.platform_model_slug) .collect::>(); + let prefs: std::collections::HashMap = storage + .list_model_source_mapping_preferences(source_kind, source_id) + .map_err(|err| format!("list preferences failed: {err}"))? + .into_iter() + .map(|p| (p.upstream_model, p.preference)) + .collect(); + let source_models = storage .list_model_source_models(Some(source_kind), Some(source_id)) .map_err(|err| format!("list source models failed: {err}"))? @@ -901,13 +946,18 @@ fn auto_associate_source_models( if existing_source_platform_mappings.contains(source_model.upstream_model.as_str()) { continue; } + let enabled = match prefs.get(source_model.upstream_model.as_str()).map(String::as_str) { + Some("unlinked") => continue, + Some(v) => v != "disabled", + None => true, + }; let mapping = ModelSourceMapping { id: generate_mapping_id(), platform_model_slug: source_model.upstream_model.clone(), source_kind: source_kind.to_string(), source_id: source_id.to_string(), upstream_model: source_model.upstream_model, - enabled: true, + enabled, priority: 0, weight: 1, billing_model_slug: None, diff --git a/crates/service/src/rpc_dispatch/apikey.rs b/crates/service/src/rpc_dispatch/apikey.rs index 6b126be19..59f152226 100644 --- a/crates/service/src/rpc_dispatch/apikey.rs +++ b/crates/service/src/rpc_dispatch/apikey.rs @@ -233,7 +233,12 @@ pub(super) fn try_handle(req: &JsonRpcRequest, actor: &RpcActor) -> Option { let id = super::str_param(req, "id").unwrap_or(""); - super::ok_or_error(apikey_models::delete_managed_model_source_mapping(id)) + let source_kind = super::str_param(req, "sourceKind").unwrap_or(""); + let source_id = super::str_param(req, "sourceId").unwrap_or(""); + let upstream_model = super::str_param(req, "upstreamModel").unwrap_or(""); + super::ok_or_error(apikey_models::delete_managed_model_source_mapping( + id, source_kind, source_id, upstream_model, + )) } "apikey/usageStats" => super::value_or_error( apikey_usage_stats::read_api_key_usage_stats_for_actor(actor) diff --git a/crates/service/src/tests/lib_tests.rs b/crates/service/src/tests/lib_tests.rs index 3bb5c1f8f..cf0e16e53 100644 --- a/crates/service/src/tests/lib_tests.rs +++ b/crates/service/src/tests/lib_tests.rs @@ -176,7 +176,12 @@ fn password_mode_can_call_admin_and_model_source_rpcs() { ), ( "apikey/modelSourceMappingDelete", - serde_json::json!({ "id": "map_test" }), + serde_json::json!({ + "id": "map_test", + "sourceKind": "openai_account", + "sourceId": "acc_test", + "upstreamModel": "gpt-test", + }), ), ] { let resp = response_result(handle_request_with_actor(