refactor(provider): simplify live_config_managed and deduplicate tolerant live config checks

- Change live_config_managed from Option<bool> to bool with #[serde(default)]
- Extract repeated tolerant live config query into check_live_config_exists helper
- Fix duplicate key generation to also check live-only provider IDs
- Fix updateProvider test to match new { provider, originalId } call signature
- Add streaming_responses test type annotation for compiler inference
This commit is contained in:
YoVinchen
2026-03-30 16:23:50 +08:00
parent dbb943852d
commit 98c8255dbb
8 changed files with 261 additions and 12 deletions
+8
View File
@@ -283,6 +283,14 @@ pub struct ProviderMeta {
/// If not set, provider ID is used automatically during format conversion.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// 用于区分仅保存在数据库中的 provider,避免其更新被 live config 解析错误误伤。
#[serde(
rename = "liveConfigManaged",
default,
skip_serializing_if = "std::ops::Not::not"
)]
pub live_config_managed: bool,
/// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
@@ -974,7 +974,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
);
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let events: Vec<Value> = chunks
+203 -6
View File
@@ -52,7 +52,66 @@ pub struct SwitchResult {
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use crate::provider::ProviderMeta;
use crate::store::AppState;
use serde_json::json;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
fn test_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|err| err.into_inner())
}
fn with_test_home<T>(test: impl FnOnce(&AppState, &Path) -> T) -> T {
let _guard = test_guard();
let temp = tempfile::tempdir().expect("tempdir");
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
let old_home = std::env::var_os("HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
std::env::set_var("HOME", temp.path());
let db = Arc::new(Database::memory().expect("in-memory database"));
let state = AppState::new(db);
let result = test(&state, temp.path());
match old_test_home {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
match old_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
result
}
fn openclaw_provider(id: &str) -> Provider {
Provider {
id: id.to_string(),
name: format!("Provider {id}"),
settings_config: json!({
"baseUrl": "https://api.deepseek.com",
"apiKey": "test-key",
"api": "openai-completions",
"models": [],
}),
website_url: None,
category: Some("custom".to_string()),
created_at: Some(1),
sort_index: Some(0),
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn validate_provider_settings_rejects_missing_auth() {
@@ -129,6 +188,78 @@ base_url = "http://localhost:8080"
"should keep mcp_servers.* base_url"
);
}
#[test]
fn rename_rejects_missing_original_provider() {
with_test_home(|state, _| {
let original = openclaw_provider("deepseek");
ProviderService::add(state, AppType::OpenClaw, original.clone(), false)
.expect("seed db-only provider");
let mut renamed = original.clone();
renamed.id = "deepseek-copy".to_string();
let err = ProviderService::update(
state,
AppType::OpenClaw,
Some("missing-provider"),
renamed,
)
.expect_err("stale originalId should be rejected");
assert!(
err.to_string().contains("Original provider"),
"expected missing original provider error, got {err:?}"
);
assert!(
state
.db
.get_provider_by_id("deepseek-copy", AppType::OpenClaw.as_str())
.expect("query renamed provider")
.is_none(),
"rename must not create a new row when originalId is stale"
);
});
}
#[test]
fn db_only_additive_update_survives_live_config_parse_errors() {
with_test_home(|state, home| {
let provider = openclaw_provider("deepseek");
ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)
.expect("seed db-only provider");
let stored = state
.db
.get_provider_by_id("deepseek", AppType::OpenClaw.as_str())
.expect("query stored provider")
.expect("provider should exist");
assert_eq!(
stored.meta.as_ref().map(|meta| meta.live_config_managed),
Some(false),
"db-only provider should be marked as not live-managed"
);
let openclaw_dir = home.join(".openclaw");
fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5")
.expect("write malformed config");
let mut updated = stored.clone();
updated.name = "DeepSeek Edited".to_string();
updated.meta.get_or_insert_with(ProviderMeta::default);
ProviderService::update(state, AppType::OpenClaw, None, updated)
.expect("db-only update should ignore live parse errors");
let saved = state
.db
.get_provider_by_id("deepseek", AppType::OpenClaw.as_str())
.expect("query updated provider")
.expect("updated provider should exist");
assert_eq!(saved.name, "DeepSeek Edited");
});
}
}
impl ProviderService {
@@ -141,6 +272,20 @@ impl ProviderService {
}
}
/// Check whether a provider exists in live config, tolerating parse errors
/// for providers that have never been written to live (`live_config_managed == false`).
fn check_live_config_exists(
app_type: &AppType,
provider_id: &str,
is_db_only: bool,
) -> Result<bool, AppError> {
if is_db_only {
Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false))
} else {
provider_exists_in_live_config(app_type, provider_id)
}
}
/// List all providers for an app type
pub fn list(
state: &AppState,
@@ -177,6 +322,12 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
if app_type.is_additive_mode() {
provider
.meta
.get_or_insert_with(Default::default)
.live_config_managed = add_to_live;
}
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
@@ -221,6 +372,9 @@ impl ProviderService {
let mut provider = provider;
let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();
let provider_id_changed = original_id != provider.id;
let existing_provider = state
.db
.get_provider_by_id(&original_id, app_type.as_str())?;
// Normalize Claude model keys
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
@@ -233,18 +387,33 @@ impl ProviderService {
));
}
if provider_exists_in_live_config(&app_type, &original_id)? {
let Some(existing_provider) = existing_provider else {
return Err(AppError::Message(format!(
"Original provider '{}' does not exist in app '{}'",
original_id,
app_type.as_str()
)));
};
let is_db_only = !existing_provider
.meta
.as_ref()
.is_some_and(|m| m.live_config_managed);
let original_in_live =
Self::check_live_config_exists(&app_type, &original_id, is_db_only)?;
if original_in_live {
return Err(AppError::Message(
"Provider key cannot be changed after the provider has been added to the app config"
.to_string(),
));
}
let next_id_in_live =
Self::check_live_config_exists(&app_type, &provider.id, is_db_only)?;
if state
.db
.get_provider_by_id(&provider.id, app_type.as_str())?
.is_some()
|| provider_exists_in_live_config(&app_type, &provider.id)?
|| next_id_in_live
{
return Err(AppError::Message(format!(
"Provider '{}' already exists in app '{}'",
@@ -253,6 +422,10 @@ impl ProviderService {
)));
}
provider
.meta
.get_or_insert_with(Default::default)
.live_config_managed = false;
state.db.save_provider(app_type.as_str(), &provider)?;
state.db.delete_provider(app_type.as_str(), &original_id)?;
@@ -263,9 +436,6 @@ impl ProviderService {
return Ok(true);
}
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider
// already exists in live config. Editing a DB-only provider must not auto-add it.
if app_type.is_additive_mode() {
@@ -299,13 +469,31 @@ impl ProviderService {
}
return Ok(true);
}
if !provider_exists_in_live_config(&app_type, &provider.id)? {
let is_db_only = !provider
.meta
.as_ref()
.is_some_and(|m| m.live_config_managed);
let live_config_managed =
Self::check_live_config_exists(&app_type, &provider.id, is_db_only)?;
provider
.meta
.get_or_insert_with(Default::default)
.live_config_managed = live_config_managed;
// Save to database after live-config presence is resolved so parse errors
// do not report failure after already mutating DB state.
state.db.save_provider(app_type.as_str(), &provider)?;
if !live_config_managed {
return Ok(true);
}
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
return Ok(true);
}
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// For other apps: Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
@@ -474,6 +662,15 @@ impl ProviderService {
)));
}
}
if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? {
provider
.meta
.get_or_insert_with(Default::default)
.live_config_managed = false;
state.db.save_provider(app_type.as_str(), &provider)?;
}
Ok(())
}
+13 -1
View File
@@ -615,7 +615,19 @@ function App() {
};
if (activeApp === "opencode" || activeApp === "openclaw") {
const existingKeys = Object.keys(providers);
const liveProviderIds =
activeApp === "opencode"
? await queryClient.ensureQueryData({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
})
: await queryClient.ensureQueryData({
queryKey: openclawKeys.liveProviderIds,
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
});
const existingKeys = Array.from(
new Set([...Object.keys(providers), ...liveProviderIds]),
);
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
provider.id,
existingKeys,
+4 -1
View File
@@ -169,7 +169,10 @@ describe("useProviderActions", () => {
await result.current.updateProvider(provider);
});
expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider);
expect(updateProviderMutateAsync).toHaveBeenCalledWith({
provider,
originalId: undefined,
});
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
});
+4 -2
View File
@@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import {
resetProviderState,
setCurrentProviderId,
setLiveProviderIds,
setProviders,
} from "../msw/state";
import { emitTauriEvent } from "../msw/tauriMocks";
@@ -239,7 +240,7 @@ describe("App integration with MSW", () => {
});
});
it("duplicates openclaw providers with a generated provider key", async () => {
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {
setProviders("openclaw", {
deepseek: {
id: "deepseek",
@@ -256,6 +257,7 @@ describe("App integration with MSW", () => {
},
});
setCurrentProviderId("openclaw", "deepseek");
setLiveProviderIds("openclaw", ["deepseek-copy"]);
const { default: App } = await import("@/App");
renderApp(App);
@@ -272,7 +274,7 @@ describe("App integration with MSW", () => {
await waitFor(() => {
const providerList = screen.getByTestId("provider-list").textContent;
expect(providerList).toContain("deepseek-copy");
expect(providerList).toContain("deepseek-copy-2");
expect(providerList).toContain("DeepSeek copy");
});
+6 -1
View File
@@ -6,6 +6,7 @@ import {
deleteProvider,
deleteSession,
getCurrentProviderId,
getLiveProviderIds,
getSessionMessages,
getProviders,
listProviders,
@@ -67,8 +68,12 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/get_opencode_live_provider_ids`, () =>
success(getLiveProviderIds("opencode")),
),
http.post(`${TAURI_ENDPOINT}/get_openclaw_live_provider_ids`, () =>
success([]),
success(getLiveProviderIds("openclaw")),
),
http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () =>
+20
View File
@@ -10,6 +10,7 @@ import type {
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
type CurrentProviderState = Record<AppId, string>;
type McpConfigState = Record<AppId, Record<string, McpServer>>;
type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>;
const createDefaultProviders = (): ProvidersByApp => ({
claude: {
@@ -77,6 +78,10 @@ const createDefaultCurrent = (): CurrentProviderState => ({
let providers = createDefaultProviders();
let current = createDefaultCurrent();
let liveProviderIds: LiveProviderIdsByApp = {
opencode: [],
openclaw: [],
};
let settingsState: Settings = {
showInTray: true,
minimizeToTrayOnClose: true,
@@ -184,6 +189,10 @@ const cloneProviders = (value: ProvidersByApp) =>
export const resetProviderState = () => {
providers = createDefaultProviders();
current = createDefaultCurrent();
liveProviderIds = {
opencode: [],
openclaw: [],
};
sessionsState = createDefaultSessions();
sessionMessagesState = createDefaultSessionMessages();
settingsState = {
@@ -243,6 +252,17 @@ export const getProviders = (appType: AppId) =>
export const getCurrentProviderId = (appType: AppId) => current[appType] ?? "";
export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [
...liveProviderIds[appType],
];
export const setLiveProviderIds = (
appType: "opencode" | "openclaw",
ids: string[],
) => {
liveProviderIds[appType] = [...ids];
};
export const setCurrentProviderId = (appType: AppId, providerId: string) => {
current[appType] = providerId;
};