diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs
index f186d150d..8f4f07790 100644
--- a/src-tauri/src/commands/provider.rs
+++ b/src-tauri/src/commands/provider.rs
@@ -36,9 +36,11 @@ pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
+ #[allow(non_snake_case)] addToLive: Option,
) -> Result {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
- ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
+ ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
+ .map_err(|e| e.to_string())
}
#[tauri::command]
@@ -46,9 +48,11 @@ pub fn update_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
+ #[allow(non_snake_case)] originalId: Option,
) -> Result {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
- ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
+ ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
+ .map_err(|e| e.to_string())
}
#[tauri::command]
diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs
index cdb0f3e39..d94189481 100644
--- a/src-tauri/src/deeplink/provider.rs
+++ b/src-tauri/src/deeplink/provider.rs
@@ -110,7 +110,7 @@ pub fn import_provider_from_deeplink(
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
- ProviderService::add(state, app_type.clone(), provider)?;
+ ProviderService::add(state, app_type.clone(), provider, true)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs
index 24ef7999c..e8a801325 100644
--- a/src-tauri/src/openclaw_config.rs
+++ b/src-tauri/src/openclaw_config.rs
@@ -8,7 +8,6 @@ use crate::error::AppError;
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
use chrono::Local;
use indexmap::IndexMap;
-use json_five::parser::{FormatConfiguration, TrailingComma};
use json_five::rt::parser::{
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
@@ -490,11 +489,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
}
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result {
- let source = json_five::to_string_formatted(
- value,
- FormatConfiguration::with_indent(2, TrailingComma::NONE),
- )
- .map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?;
+ // `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays.
+ // Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and
+ // can still be parsed back into the round-trip AST we use for insertion.
+ let source = serde_json::to_string_pretty(value)
+ .map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?;
let adjusted = reindent_json5_block(&source, parent_indent);
let text = rt_from_str(&adjusted).map_err(|e| {
@@ -1051,4 +1050,37 @@ mod tests {
assert!(err.to_string().contains("OpenClaw config changed on disk"));
});
}
+
+ #[test]
+ fn remove_last_provider_writes_empty_providers_without_panic() {
+ let source = r#"{
+ models: {
+ mode: 'merge',
+ providers: {
+ '1-copy': {
+ api: 'anthropic-messages',
+ },
+ },
+ },
+}
+"#;
+
+ with_test_paths(source, |_| {
+ let outcome = remove_provider("1-copy").unwrap();
+ assert!(outcome.backup_path.is_some());
+
+ let config = read_openclaw_config().unwrap();
+ let providers = config
+ .get("models")
+ .and_then(|models| models.get("providers"))
+ .and_then(Value::as_object)
+ .cloned()
+ .unwrap_or_default();
+
+ assert!(providers.is_empty());
+
+ let written = fs::read_to_string(get_openclaw_config_path()).unwrap();
+ assert!(written.contains("\"providers\": {}"));
+ });
+ }
}
diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs
index 39ece0ed8..9b624a214 100644
--- a/src-tauri/src/provider.rs
+++ b/src-tauri/src/provider.rs
@@ -283,6 +283,10 @@ 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,
+ /// 累加模式应用中,该 provider 是否已写入 live config。
+ /// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
+ #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
+ pub live_config_managed: Option,
/// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs
index 9e534cee4..a91a35a5a 100644
--- a/src-tauri/src/services/omo.rs
+++ b/src-tauri/src/services/omo.rs
@@ -1,6 +1,7 @@
-use crate::config::write_json_file;
+use crate::config::{atomic_write, write_json_file};
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
+use crate::provider::Provider;
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -133,6 +134,68 @@ impl OmoService {
}
}
+ fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
+ let agents = provider.settings_config.get("agents").cloned();
+ let categories = if v.has_categories {
+ provider.settings_config.get("categories").cloned()
+ } else {
+ None
+ };
+ let other_fields = provider.settings_config.get("otherFields").cloned();
+ (agents, categories, other_fields)
+ }
+
+ fn snapshot_config_file(path: &Path) -> Result
)}
{!(
- existingOpencodeKeys.includes(
+ additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
- ) && !isEditMode
+ ) && !isProviderKeyLocked
) &&
(opencodeForm.opencodeProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
)) && (
- {t("opencode.providerKeyHint")}
+ {isProviderKeyLocked
+ ? t("opencode.providerKeyLockedHint", {
+ defaultValue:
+ "该供应商已添加到应用配置中,供应商标识不可修改",
+ })
+ : t("opencode.providerKeyHint")}
)}
@@ -1312,12 +1416,14 @@ export function ProviderForm({
)
}
placeholder={t("openclaw.providerKeyPlaceholder")}
- disabled={isEditMode}
+ disabled={
+ isProviderKeyLocked || isProviderKeyLockStateLoading
+ }
className={
- (openclawForm.existingOpenclawKeys.includes(
+ (additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) &&
- !isEditMode) ||
+ !isProviderKeyLocked) ||
(openclawForm.openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
@@ -1326,10 +1432,10 @@ export function ProviderForm({
: ""
}
/>
- {openclawForm.existingOpenclawKeys.includes(
+ {additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) &&
- !isEditMode && (
+ !isProviderKeyLocked && (
{t("openclaw.providerKeyDuplicate")}
@@ -1343,16 +1449,21 @@ export function ProviderForm({
)}
{!(
- openclawForm.existingOpenclawKeys.includes(
+ additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
- ) && !isEditMode
+ ) && !isProviderKeyLocked
) &&
(openclawForm.openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
)) && (
- {t("openclaw.providerKeyHint")}
+ {isProviderKeyLocked
+ ? t("openclaw.providerKeyLockedHint", {
+ defaultValue:
+ "该供应商已添加到应用配置中,供应商标识不可修改",
+ })
+ : t("openclaw.providerKeyHint")}
)}
diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts
index 344616843..a694e9484 100644
--- a/src/hooks/useProviderActions.ts
+++ b/src/hooks/useProviderActions.ts
@@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
provider: Omit & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
+ addToLive?: boolean;
},
) => {
await addProviderMutation.mutateAsync(provider);
@@ -120,8 +121,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
// 更新供应商
const updateProvider = useCallback(
- async (provider: Provider) => {
- await updateProviderMutation.mutateAsync(provider);
+ async (provider: Provider, originalId?: string) => {
+ await updateProviderMutation.mutateAsync({ provider, originalId });
// 更新托盘菜单(失败不影响主操作)
try {
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 70b7f85f5..228b474e3 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -933,7 +933,8 @@
"modelsRequired": "Please add at least one model",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
- "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
+ "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
+ "providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
@@ -1392,7 +1393,8 @@
"backupCreated": "Backup created: {{path}}",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
- "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
+ "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
+ "providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 82fed222f..bf3189a0c 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -933,7 +933,8 @@
"modelsRequired": "モデルを少なくとも1つ追加してください",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
- "providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
+ "providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
+ "providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
@@ -1392,7 +1393,8 @@
"backupCreated": "バックアップを作成しました: {{path}}",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
- "providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
+ "providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
+ "providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 8c435e5fe..88b6ce9ad 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -933,7 +933,8 @@
"modelsRequired": "请至少添加一个模型配置",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
- "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
+ "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
+ "providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
@@ -1392,7 +1393,8 @@
"backupCreated": "已创建备份:{{path}}",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
- "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
+ "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
+ "providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts
index 551b05951..89b6b7c7f 100644
--- a/src/lib/api/providers.ts
+++ b/src/lib/api/providers.ts
@@ -34,12 +34,24 @@ export const providersApi = {
return await invoke("get_current_provider", { app: appId });
},
- async add(provider: Provider, appId: AppId): Promise {
- return await invoke("add_provider", { provider, app: appId });
+ async add(
+ provider: Provider,
+ appId: AppId,
+ addToLive?: boolean,
+ ): Promise {
+ return await invoke("add_provider", { provider, app: appId, addToLive });
},
- async update(provider: Provider, appId: AppId): Promise {
- return await invoke("update_provider", { provider, app: appId });
+ async update(
+ provider: Provider,
+ appId: AppId,
+ originalId?: string,
+ ): Promise {
+ return await invoke("update_provider", {
+ provider,
+ app: appId,
+ originalId,
+ });
},
async delete(id: string, appId: AppId): Promise {
diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts
index e87ab5c13..e5ab2815c 100644
--- a/src/lib/query/mutations.ts
+++ b/src/lib/query/mutations.ts
@@ -15,7 +15,10 @@ export const useAddProviderMutation = (appId: AppId) => {
return useMutation({
mutationFn: async (
- providerInput: Omit & { providerKey?: string },
+ providerInput: Omit & {
+ providerKey?: string;
+ addToLive?: boolean;
+ },
) => {
let id: string;
@@ -36,7 +39,7 @@ export const useAddProviderMutation = (appId: AppId) => {
id = generateUUID();
}
- const { providerKey: _providerKey, ...rest } = providerInput;
+ const { providerKey: _providerKey, addToLive, ...rest } = providerInput;
const newProvider: Provider = {
...rest,
@@ -45,7 +48,7 @@ export const useAddProviderMutation = (appId: AppId) => {
};
delete (newProvider as any).providerKey;
- await providersApi.add(newProvider, appId);
+ await providersApi.add(newProvider, appId, addToLive);
return newProvider;
},
onSuccess: async () => {
@@ -107,8 +110,14 @@ export const useUpdateProviderMutation = (appId: AppId) => {
const { t } = useTranslation();
return useMutation({
- mutationFn: async (provider: Provider) => {
- await providersApi.update(provider, appId);
+ mutationFn: async ({
+ provider,
+ originalId,
+ }: {
+ provider: Provider;
+ originalId?: string;
+ }) => {
+ await providersApi.update(provider, appId, originalId);
return provider;
},
onSuccess: async () => {
diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx
index d6d858d72..8a7a2e8b4 100644
--- a/tests/hooks/useProviderActions.test.tsx
+++ b/tests/hooks/useProviderActions.test.tsx
@@ -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);
});
diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx
index 14f8439da..e3a26d1c6 100644
--- a/tests/integration/App.test.tsx
+++ b/tests/integration/App.test.tsx
@@ -2,7 +2,13 @@ import { Suspense, type ComponentType } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
-import { resetProviderState } from "../msw/state";
+import { providersApi } from "@/lib/api/providers";
+import {
+ resetProviderState,
+ setCurrentProviderId,
+ setLiveProviderIds,
+ setProviders,
+} from "../msw/state";
import { emitTauriEvent } from "../msw/tauriMocks";
const toastSuccessMock = vi.fn();
@@ -75,8 +81,11 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({