feat(provider): additive provider key lifecycle & fix openclaw serializer panic (#1724)

* feat(provider): support additive provider key lifecycle management

Add `addToLive` parameter to add_provider so callers can opt out of
writing to the live config (e.g. when duplicating an inactive provider).
Add `originalId` parameter to update_provider to support provider key
renames — the old key is removed from live config before the new one
is written.

Frontend: ProviderForm now exposes provider-key input for openclaw app
type, and EditProviderDialog forwards originalId on save. Deep-link
import passes addToLive=true to preserve existing behavior.

* test(provider): add integration tests for additive provider key flows

Cover openclaw provider duplication scenario to verify that a generated
provider key is assigned automatically. Add MSW handlers for
get_openclaw_live_provider_ids, get_openclaw_default_model,
scan_openclaw_config_health, and check_env_conflicts endpoints.
Update EditProviderDialog mock to pass originalId alongside provider.

* fix(openclaw): replace json-five serializer to prevent panic on empty collections

json-five 0.3.1 panics when pretty-printing nested empty maps/arrays.
Switch value_to_rt_value() to serde_json::to_string_pretty() which
produces valid JSON5 output without the panic. Add regression test for
removing the last provider (empty providers map).

* style: apply rustfmt formatting to proxy and provider modules

Reformat chained .header() calls in ClaudeAdapter and StreamCheckService
for consistent alignment. Reorder imports alphabetically in stream_check.
Fix trailing whitespace in transform.rs and merge import lines in
provider/mod.rs.

* style: fix clippy warnings in live.rs and tray.rs

* 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

* fix(provider): distinguish legacy providers from db-only when tolerating live config errors

Change `ProviderMeta.live_config_managed` from `bool` to `Option<bool>`
to introduce a three-state semantic:
- `Some(true)`: provider has been written to live config
- `Some(false)`: explicitly db-only, never written to live config
- `None`: legacy data or unknown state (pre-existing providers)

Previously, legacy providers defaulted to `live_config_managed = false`
via `#[serde(default)]`, which silently swallowed live config parse
errors. This could mask genuine configuration issues for providers that
had actually been synced to live config before the field was introduced.

Now, only providers with an explicit `Some(false)` marker tolerate parse
errors; legacy `None` providers surface errors as before, preserving
safety for already-managed configurations.

Also wrap the `ensureQueryData` call for live provider IDs during
duplication in a try/catch so that a malformed config file shows a
user-facing toast instead of silently failing.

Add tests for both the legacy error propagation path and the frontend
duplication failure scenario.

* refactor(provider): unify OMO variant updates with atomic file-then-db writes and rollback

Consolidate the duplicated omo/omo-slim update branches into a single
match on the variant. Write the OMO config file from the in-memory
provider state *before* persisting to the database, so a file-write or
plugin-sync failure leaves the database unchanged. If `add_plugin`
fails after the config file is already written, roll back to the
previous on-disk contents via snapshot/restore.

Also:
- `sync_all_providers_to_live` now skips db-only providers
  (`live_config_managed == Some(false)`) instead of attempting to write
  them to live config.
- `import_{opencode,openclaw}_providers_from_live` mark imported
  providers as `live_config_managed: Some(true)` so they are correctly
  recognized during subsequent syncs.
- Extract OmoService helpers: `profile_data_from_provider`,
  `snapshot_config_file`, `restore_config_file`, `write_profile_config`,
  and the new public `write_provider_config_to_file`.
- Add 9 new tests covering sync skip, legacy restore, import marking,
  OMO persistence, file-write failure, and plugin-sync rollback.

* fix(provider): fix additive provider delete/switch regressions and redundancy

- fix(delete): replace stale live_config_managed flag check with
  check_live_config_exists so providers written to live before the
  flag-flip logic was introduced are still cleaned up on delete
- fix(switch): make write_live_with_common_config return Err instead of
  silently returning Ok when config structure is invalid, preventing
  live_config_managed from being incorrectly flipped to true
- fix(update): block provider key rename for OMO/OMO Slim categories to
  prevent orphaned current-state markers breaking OMO file syncs
- fix(switch): flip live_config_managed to true after successful live
  write for DB-only additive providers so sync_all_providers_to_live
  includes them on future syncs; roll back live write if DB update fails
- refactor(delete): merge symmetric OMO/OMO-Slim blocks into single
  match-on-variant path; hoist DB read to top of additive branch
- refactor(remove_from_live_config): merge OMO/OMO-Slim if/else-if
  into single match-on-variant path
- refactor(switch_normal): merge two OMO/OMO-Slim if blocks into one
  OpenCode guard with (enable, disable) variant pair
- fix(update): remove redundant duplicate return Ok(true) after OMO
  current-state write

* fix(test): use preferred_filename after OMO field rename

The merge from main brought in #1746 which renamed
OmoVariant.filename → preferred_filename, but the test helper
omo_config_path() was not updated, breaking compilation of all
new provider tests.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Dex Miller
2026-04-01 21:16:41 +08:00
committed by GitHub
parent 72f3610ac0
commit 3553d05bf0
20 changed files with 1422 additions and 213 deletions

View File

@@ -36,9 +36,11 @@ pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
#[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> {
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<String>,
) -> Result<bool, String> {
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]

View File

@@ -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) {

View File

@@ -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<RtJSONValue, AppError> {
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\": {}"));
});
}
}

View File

@@ -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<String>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
pub live_config_managed: Option<bool>,
/// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]

View File

@@ -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<Option<Vec<u8>>, AppError> {
if !path.exists() {
return Ok(None);
}
std::fs::read(path)
.map(Some)
.map_err(|e| AppError::io(path, e))
}
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
match snapshot {
Some(bytes) => atomic_write(path, bytes),
None => {
if path.exists() {
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
}
Ok(())
}
}
}
fn write_profile_config(
v: &OmoVariant,
profile_data: Option<&OmoProfileData>,
) -> Result<(), AppError> {
let merged = Self::build_config(v, profile_data);
let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let previous_contents = Self::snapshot_config_file(&config_path)?;
write_json_file(&config_path, &merged)?;
if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
if let Err(rollback_err) =
Self::restore_config_file(&config_path, previous_contents.as_deref())
{
log::warn!(
"Failed to roll back {} config after plugin sync error: {}",
v.label,
rollback_err
);
}
return Err(err);
}
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
}
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
@@ -153,28 +216,18 @@ impl OmoService {
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
let profile_data = current_omo.as_ref().map(|p| {
let agents = p.settings_config.get("agents").cloned();
let categories = if v.has_categories {
p.settings_config.get("categories").cloned()
} else {
None
};
let other_fields = p.settings_config.get("otherFields").cloned();
(agents, categories, other_fields)
});
let profile_data = current_omo
.as_ref()
.map(|provider| Self::profile_data_from_provider(provider, v));
Self::write_profile_config(v, profile_data.as_ref())
}
let merged = Self::build_config(v, profile_data.as_ref());
let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin(v.plugin_name)?;
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
pub fn write_provider_config_to_file(
provider: &Provider,
v: &OmoVariant,
) -> Result<(), AppError> {
let profile_data = Self::profile_data_from_provider(provider, v);
Self::write_profile_config(v, Some(&profile_data))
}
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {

View File

@@ -33,6 +33,19 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
v
}
pub(crate) fn provider_exists_in_live_config(
app_type: &AppType,
provider_id: &str,
) -> Result<bool, AppError> {
match app_type {
AppType::OpenCode => crate::opencode_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
AppType::OpenClaw => crate::openclaw_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
_ => Ok(false),
}
}
fn json_is_subset(target: &Value, source: &Value) -> bool {
match source {
Value::Object(source_map) => {
@@ -727,10 +740,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
provider.id
);
} else {
log::error!(
"OpenCode provider '{}' has invalid config structure, skipping write",
return Err(AppError::Message(format!(
"OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')",
provider.id
);
)));
}
}
}
@@ -769,10 +782,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
provider.id
);
} else {
log::error!(
"OpenClaw provider '{}' has invalid config structure, skipping write",
return Err(AppError::Message(format!(
"OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')",
provider.id
);
)));
}
}
}
@@ -787,23 +800,30 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
/// Used for OpenCode and other additive mode applications.
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut synced_count = 0usize;
for provider in providers.values() {
if provider
.meta
.as_ref()
.and_then(|meta| meta.live_config_managed)
== Some(false)
{
continue;
}
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
provider.id
);
// Continue syncing other providers, don't abort
continue;
}
synced_count += 1;
}
log::info!(
"Synced {} {:?} providers to live config",
providers.len(),
app_type
);
log::info!("Synced {synced_count} {app_type:?} providers to live config");
Ok(())
}
@@ -1207,12 +1227,16 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
};
// Create provider
let provider = Provider::with_id(
let mut provider = Provider::with_id(
id.clone(),
config.name.clone().unwrap_or_else(|| id.clone()),
settings_config,
None,
);
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
..Default::default()
});
// Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) {
@@ -1277,7 +1301,11 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
.unwrap_or_else(|| id.clone());
// Create provider
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None);
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
..Default::default()
});
// Save to database
if let Err(e) = state.db.save_provider("openclaw", &provider) {

File diff suppressed because it is too large Load Diff

View File

@@ -533,8 +533,14 @@ function App() {
}
};
const handleEditProvider = async (provider: Provider) => {
await updateProvider(provider);
const handleEditProvider = async ({
provider,
originalId,
}: {
provider: Provider;
originalId?: string;
}) => {
await updateProvider(provider, originalId);
setEditingProvider(null);
};
@@ -571,7 +577,7 @@ function App() {
setConfirmAction(null);
};
const generateUniqueOpencodeKey = (
const generateUniqueProviderCopyKey = (
originalKey: string,
existingKeys: string[],
): string => {
@@ -594,6 +600,7 @@ function App() {
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
providerKey?: string;
addToLive?: boolean;
} = {
name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
@@ -607,12 +614,40 @@ function App() {
iconColor: provider.iconColor,
};
if (activeApp === "opencode") {
const existingKeys = Object.keys(providers);
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
if (activeApp === "opencode" || activeApp === "openclaw") {
let liveProviderIds: string[] = [];
try {
liveProviderIds =
activeApp === "opencode"
? await queryClient.ensureQueryData({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
})
: await queryClient.ensureQueryData({
queryKey: openclawKeys.liveProviderIds,
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
});
} catch (error) {
console.error(
"[App] Failed to load live provider IDs for duplication",
error,
);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.duplicateLiveIdsLoadFailed", {
defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
return;
}
const existingKeys = Array.from(
new Set([...Object.keys(providers), ...liveProviderIds]),
);
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
provider.id,
existingKeys,
);
duplicatedProvider.addToLive = false;
}
if (provider.sortIndex !== undefined) {

View File

@@ -14,7 +14,10 @@ interface EditProviderDialogProps {
open: boolean;
provider: Provider | null;
onOpenChange: (open: boolean) => void;
onSubmit: (provider: Provider) => Promise<void> | void;
onSubmit: (payload: {
provider: Provider;
originalId?: string;
}) => Promise<void> | void;
appId: AppId;
isProxyTakeover?: boolean; // 代理接管模式下不读取 live避免显示被接管后的代理配置
}
@@ -165,9 +168,15 @@ export function EditProviderDialog({
string,
unknown
>;
const nextProviderId =
(appId === "opencode" || appId === "openclaw") &&
values.providerKey?.trim()
? values.providerKey.trim()
: provider.id;
const updatedProvider: Provider = {
...provider,
id: nextProviderId,
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -179,10 +188,13 @@ export function EditProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
await onSubmit(updatedProvider);
await onSubmit({
provider: updatedProvider,
originalId: provider.id,
});
onOpenChange(false);
},
[onSubmit, onOpenChange, provider],
[appId, onSubmit, onOpenChange, provider],
);
if (!provider || !initialData) {

View File

@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppId } from "@/lib/api";
import { providersApi, type AppId } from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
@@ -91,6 +92,7 @@ import {
normalizePricingSource,
} from "./helpers/opencodeFormUtils";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
type PresetEntry = {
id: string;
@@ -577,6 +579,15 @@ export function ProviderForm({
existingOpencodeKeys,
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
const {
data: opencodeLiveProviderIds = [],
isLoading: isOpencodeLiveProviderIdsLoading,
} = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode" && !isAnyOmoCategory,
});
const opencodeForm = useOpencodeFormState({
initialData,
appId,
@@ -605,6 +616,78 @@ export function ProviderForm({
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const {
data: openclawLiveProviderIds = [],
isLoading: isOpenclawLiveProviderIdsLoading,
} = useOpenClawLiveProviderIds(appId === "openclaw");
const additiveExistingProviderKeys = useMemo(() => {
if (appId === "opencode" && !isAnyOmoCategory) {
return Array.from(
new Set(
[...existingOpencodeKeys, ...opencodeLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
if (appId === "openclaw") {
return Array.from(
new Set(
[
...openclawForm.existingOpenclawKeys,
...openclawLiveProviderIds,
].filter((key) => key !== providerId),
),
);
}
return [];
}, [
appId,
existingOpencodeKeys,
isAnyOmoCategory,
openclawForm.existingOpenclawKeys,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const isProviderKeyLockStateLoading = useMemo(() => {
if (!isEditMode) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return isOpencodeLiveProviderIdsLoading;
}
if (appId === "openclaw") {
return isOpenclawLiveProviderIdsLoading;
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
isOpenclawLiveProviderIdsLoading,
isOpencodeLiveProviderIdsLoading,
]);
const isProviderKeyLocked = useMemo(() => {
if (!isEditMode || !providerId) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return opencodeLiveProviderIds.includes(providerId);
}
if (appId === "openclaw") {
return openclawLiveProviderIds.includes(providerId);
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -641,9 +724,17 @@ export function ProviderForm({
toast.error(t("opencode.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isEditMode &&
existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey)
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
) {
toast.error(t("opencode.providerKeyDuplicate"));
return;
@@ -665,11 +756,17 @@ export function ProviderForm({
toast.error(t("openclaw.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isEditMode &&
openclawForm.existingOpenclawKeys.includes(
openclawForm.openclawProviderKey,
)
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
) {
toast.error(t("openclaw.providerKeyDuplicate"));
return;
@@ -1253,12 +1350,14 @@ export function ProviderForm({
)
}
placeholder={t("opencode.providerKeyPlaceholder")}
disabled={isEditMode}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(existingOpencodeKeys.includes(
(additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isEditMode) ||
!isProviderKeyLocked) ||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
@@ -1267,10 +1366,10 @@ export function ProviderForm({
: ""
}
/>
{existingOpencodeKeys.includes(
{additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isEditMode && (
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyDuplicate")}
</p>
@@ -1284,16 +1383,21 @@ export function ProviderForm({
</p>
)}
{!(
existingOpencodeKeys.includes(
additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) && !isEditMode
) && !isProviderKeyLocked
) &&
(opencodeForm.opencodeProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{t("opencode.providerKeyHint")}
{isProviderKeyLocked
? t("opencode.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("opencode.providerKeyHint")}
</p>
)}
</div>
@@ -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 && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyDuplicate")}
</p>
@@ -1343,16 +1449,21 @@ export function ProviderForm({
</p>
)}
{!(
openclawForm.existingOpenclawKeys.includes(
additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) && !isEditMode
) && !isProviderKeyLocked
) &&
(openclawForm.openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{t("openclaw.providerKeyHint")}
{isProviderKeyLocked
? t("openclaw.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("openclaw.providerKeyHint")}
</p>
)}
</div>

View File

@@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
provider: Omit<Provider, "id"> & {
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 {

View File

@@ -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.",

View File

@@ -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": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",

View File

@@ -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": "标识格式无效,只能使用小写字母、数字和连字符",

View File

@@ -34,12 +34,24 @@ export const providersApi = {
return await invoke("get_current_provider", { app: appId });
},
async add(provider: Provider, appId: AppId): Promise<boolean> {
return await invoke("add_provider", { provider, app: appId });
async add(
provider: Provider,
appId: AppId,
addToLive?: boolean,
): Promise<boolean> {
return await invoke("add_provider", { provider, app: appId, addToLive });
},
async update(provider: Provider, appId: AppId): Promise<boolean> {
return await invoke("update_provider", { provider, app: appId });
async update(
provider: Provider,
appId: AppId,
originalId?: string,
): Promise<boolean> {
return await invoke("update_provider", {
provider,
app: appId,
originalId,
});
},
async delete(id: string, appId: AppId): Promise<boolean> {

View File

@@ -15,7 +15,10 @@ export const useAddProviderMutation = (appId: AppId) => {
return useMutation({
mutationFn: async (
providerInput: Omit<Provider, "id"> & { providerKey?: string },
providerInput: Omit<Provider, "id"> & {
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 () => {

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);
});

View File

@@ -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", () => ({
<button
onClick={() =>
onSubmit({
...provider,
name: `${provider.name}-edited`,
provider: {
...provider,
name: `${provider.name}-edited`,
},
originalId: provider.id,
})
}
>
@@ -114,6 +123,7 @@ vi.mock("@/components/AppSwitcher", () => ({
<span>{activeApp}</span>
<button onClick={() => onSwitch("claude")}>switch-claude</button>
<button onClick={() => onSwitch("codex")}>switch-codex</button>
<button onClick={() => onSwitch("openclaw")}>switch-openclaw</button>
</div>
),
}));
@@ -230,4 +240,95 @@ describe("App integration with MSW", () => {
expect(toastErrorMock).toHaveBeenCalled();
});
});
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {
setProviders("openclaw", {
deepseek: {
id: "deepseek",
name: "DeepSeek",
settingsConfig: {
baseUrl: "https://api.deepseek.com",
apiKey: "test-key",
api: "openai-completions",
models: [],
},
category: "custom",
sortIndex: 0,
createdAt: Date.now(),
},
});
setCurrentProviderId("openclaw", "deepseek");
setLiveProviderIds("openclaw", ["deepseek-copy"]);
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"deepseek",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() => {
const providerList = screen.getByTestId("provider-list").textContent;
expect(providerList).toContain("deepseek-copy-2");
expect(providerList).toContain("DeepSeek copy");
});
expect(toastErrorMock).not.toHaveBeenCalledWith(
expect.stringContaining("Provider key is required for openclaw"),
);
});
it("shows toast when duplicate cannot load live provider ids", async () => {
setProviders("openclaw", {
deepseek: {
id: "deepseek",
name: "DeepSeek",
settingsConfig: {
baseUrl: "https://api.deepseek.com",
apiKey: "test-key",
api: "openai-completions",
models: [],
},
category: "custom",
sortIndex: 0,
createdAt: Date.now(),
},
});
setCurrentProviderId("openclaw", "deepseek");
const liveIdsSpy = vi
.spyOn(providersApi, "getOpenClawLiveProviderIds")
.mockRejectedValueOnce(new Error("broken config"));
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"deepseek",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
expect.stringContaining("读取配置中的供应商标识失败"),
);
});
expect(screen.getByTestId("provider-list").textContent).not.toContain(
"deepseek-copy",
);
liveIdsSpy.mockRestore();
});
});

View File

@@ -6,6 +6,7 @@ import {
deleteProvider,
deleteSession,
getCurrentProviderId,
getLiveProviderIds,
getSessionMessages,
getProviders,
listProviders,
@@ -67,6 +68,20 @@ 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(getLiveProviderIds("openclaw")),
),
http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () =>
success({ primary: null, fallback: [] }),
),
http.post(`${TAURI_ENDPOINT}/scan_openclaw_config_health`, () => success([])),
http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => {
const { id, app } = await withJson<{ id: string; app: AppId }>(request);
const providers = listProviders(app);
@@ -197,6 +212,8 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
http.post(`${TAURI_ENDPOINT}/check_env_conflicts`, () => success([])),
http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {
const { settings } = await withJson<{ settings: Settings }>(request);
setSettings(settings);

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;
};