mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
3553d05bf0
* 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>
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { useTranslation } from "react-i18next";
|
|
import { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api";
|
|
import type {
|
|
Provider,
|
|
UsageScript,
|
|
OpenClawProviderConfig,
|
|
OpenClawDefaultModel,
|
|
} from "@/types";
|
|
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
|
import {
|
|
useAddProviderMutation,
|
|
useUpdateProviderMutation,
|
|
useDeleteProviderMutation,
|
|
useSwitchProviderMutation,
|
|
} from "@/lib/query";
|
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
|
import { openclawKeys } from "@/hooks/useOpenClaw";
|
|
|
|
/**
|
|
* Hook for managing provider actions (add, update, delete, switch)
|
|
* Extracts business logic from App.tsx
|
|
*/
|
|
export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
const addProviderMutation = useAddProviderMutation(activeApp);
|
|
const updateProviderMutation = useUpdateProviderMutation(activeApp);
|
|
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
|
|
const switchProviderMutation = useSwitchProviderMutation(activeApp);
|
|
|
|
// Claude 插件同步逻辑
|
|
const syncClaudePlugin = useCallback(
|
|
async (provider: Provider) => {
|
|
if (activeApp !== "claude") return;
|
|
|
|
try {
|
|
const settings = await settingsApi.get();
|
|
if (!settings?.enableClaudePluginIntegration) {
|
|
return;
|
|
}
|
|
|
|
const isOfficial = provider.category === "official";
|
|
await settingsApi.applyClaudePluginConfig({ official: isOfficial });
|
|
|
|
// 静默执行,不显示成功通知
|
|
} catch (error) {
|
|
const detail =
|
|
extractErrorMessage(error) ||
|
|
t("notifications.syncClaudePluginFailed", {
|
|
defaultValue: "同步 Claude 插件失败",
|
|
});
|
|
toast.error(detail, { duration: 4200 });
|
|
}
|
|
},
|
|
[activeApp, t],
|
|
);
|
|
|
|
// 添加供应商
|
|
const addProvider = useCallback(
|
|
async (
|
|
provider: Omit<Provider, "id"> & {
|
|
providerKey?: string;
|
|
suggestedDefaults?: OpenClawSuggestedDefaults;
|
|
addToLive?: boolean;
|
|
},
|
|
) => {
|
|
await addProviderMutation.mutateAsync(provider);
|
|
|
|
// OpenClaw: register models to allowlist after adding provider
|
|
if (activeApp === "openclaw" && provider.suggestedDefaults) {
|
|
const { model, modelCatalog } = provider.suggestedDefaults;
|
|
let modelsRegistered = false;
|
|
|
|
try {
|
|
// 1. Merge model catalog (allowlist)
|
|
if (modelCatalog && Object.keys(modelCatalog).length > 0) {
|
|
const existingCatalog = (await openclawApi.getModelCatalog()) || {};
|
|
const mergedCatalog = { ...existingCatalog, ...modelCatalog };
|
|
await openclawApi.setModelCatalog(mergedCatalog);
|
|
await queryClient.invalidateQueries({
|
|
queryKey: openclawKeys.health,
|
|
});
|
|
modelsRegistered = true;
|
|
}
|
|
|
|
// 2. Set default model (only if not already set)
|
|
if (model) {
|
|
const existingDefault = await openclawApi.getDefaultModel();
|
|
if (!existingDefault?.primary) {
|
|
await openclawApi.setDefaultModel(model);
|
|
await queryClient.invalidateQueries({
|
|
queryKey: openclawKeys.health,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Show success toast if models were registered
|
|
if (modelsRegistered) {
|
|
toast.success(
|
|
t("notifications.openclawModelsRegistered", {
|
|
defaultValue: "模型已注册到 /model 列表",
|
|
}),
|
|
{ closeButton: true },
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// Log warning but don't block main flow - provider config is already saved
|
|
console.warn(
|
|
"[OpenClaw] Failed to register models to allowlist:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
[addProviderMutation, activeApp, queryClient, t],
|
|
);
|
|
|
|
// 更新供应商
|
|
const updateProvider = useCallback(
|
|
async (provider: Provider, originalId?: string) => {
|
|
await updateProviderMutation.mutateAsync({ provider, originalId });
|
|
|
|
// 更新托盘菜单(失败不影响主操作)
|
|
try {
|
|
await providersApi.updateTrayMenu();
|
|
} catch (trayError) {
|
|
console.error(
|
|
"Failed to update tray menu after updating provider",
|
|
trayError,
|
|
);
|
|
}
|
|
},
|
|
[updateProviderMutation],
|
|
);
|
|
|
|
// 切换供应商
|
|
const switchProvider = useCallback(
|
|
async (provider: Provider) => {
|
|
const isCopilotProvider =
|
|
activeApp === "claude" &&
|
|
provider.meta?.providerType === "github_copilot";
|
|
|
|
// Determine why this provider requires the proxy
|
|
let proxyRequiredReason: string | null = null;
|
|
if (!isProxyRunning && provider.category !== "official") {
|
|
if (isCopilotProvider) {
|
|
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
|
|
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
|
|
});
|
|
} else if (
|
|
provider.meta?.apiFormat === "openai_chat" &&
|
|
activeApp === "claude"
|
|
) {
|
|
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
|
|
defaultValue: "使用 OpenAI Chat 接口格式",
|
|
});
|
|
} else if (
|
|
provider.meta?.apiFormat === "openai_responses" &&
|
|
activeApp === "claude"
|
|
) {
|
|
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
|
|
defaultValue: "使用 OpenAI Responses 接口格式",
|
|
});
|
|
} else if (
|
|
provider.meta?.isFullUrl &&
|
|
(activeApp === "claude" || activeApp === "codex")
|
|
) {
|
|
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
|
|
defaultValue: "开启了完整 URL 连接模式",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (proxyRequiredReason) {
|
|
toast.warning(
|
|
t("notifications.proxyRequiredForSwitch", {
|
|
reason: proxyRequiredReason,
|
|
defaultValue:
|
|
"此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await switchProviderMutation.mutateAsync(provider.id);
|
|
await syncClaudePlugin(provider);
|
|
|
|
// Show backfill warning if present
|
|
if (result?.warnings?.length) {
|
|
toast.warning(
|
|
t("notifications.backfillWarning", {
|
|
defaultValue:
|
|
"切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
|
|
}),
|
|
{ duration: 5000 },
|
|
);
|
|
}
|
|
|
|
// 根据供应商类型显示不同的成功提示
|
|
if (
|
|
activeApp === "claude" &&
|
|
provider.category !== "official" &&
|
|
(isCopilotProvider ||
|
|
provider.meta?.apiFormat === "openai_chat" ||
|
|
provider.meta?.apiFormat === "openai_responses")
|
|
) {
|
|
// OpenAI format provider: show proxy hint
|
|
toast.info(
|
|
isCopilotProvider
|
|
? t("notifications.copilotProxyHint")
|
|
: t("notifications.openAIFormatHint"),
|
|
{
|
|
duration: 5000,
|
|
closeButton: true,
|
|
},
|
|
);
|
|
} else {
|
|
// 普通供应商:显示切换成功
|
|
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
|
|
const isMultiProviderApp =
|
|
activeApp === "opencode" || activeApp === "openclaw";
|
|
const messageKey = isMultiProviderApp
|
|
? "notifications.addToConfigSuccess"
|
|
: "notifications.switchSuccess";
|
|
const defaultMessage = isMultiProviderApp
|
|
? "已添加到配置"
|
|
: "切换成功!";
|
|
|
|
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
|
|
closeButton: true,
|
|
});
|
|
}
|
|
} catch {
|
|
// 错误提示由 mutation 处理
|
|
}
|
|
},
|
|
[switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
|
|
);
|
|
|
|
// 删除供应商
|
|
const deleteProvider = useCallback(
|
|
async (id: string) => {
|
|
await deleteProviderMutation.mutateAsync(id);
|
|
},
|
|
[deleteProviderMutation],
|
|
);
|
|
|
|
// 保存用量脚本
|
|
const saveUsageScript = useCallback(
|
|
async (provider: Provider, script: UsageScript) => {
|
|
try {
|
|
const updatedProvider: Provider = {
|
|
...provider,
|
|
meta: {
|
|
...provider.meta,
|
|
usage_script: script,
|
|
},
|
|
};
|
|
|
|
await providersApi.update(updatedProvider, activeApp);
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["providers", activeApp],
|
|
});
|
|
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
|
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["usage", provider.id, activeApp],
|
|
});
|
|
toast.success(
|
|
t("provider.usageSaved", {
|
|
defaultValue: "用量查询配置已保存",
|
|
}),
|
|
{ closeButton: true },
|
|
);
|
|
} catch (error) {
|
|
const detail =
|
|
extractErrorMessage(error) ||
|
|
t("provider.usageSaveFailed", {
|
|
defaultValue: "用量查询配置保存失败",
|
|
});
|
|
toast.error(detail);
|
|
}
|
|
},
|
|
[activeApp, queryClient, t],
|
|
);
|
|
|
|
// Set provider as default model (OpenClaw only)
|
|
const setAsDefaultModel = useCallback(
|
|
async (provider: Provider) => {
|
|
const config = provider.settingsConfig as OpenClawProviderConfig;
|
|
if (!config.models || config.models.length === 0) {
|
|
toast.error(
|
|
t("notifications.openclawNoModels", {
|
|
defaultValue: "该供应商没有配置模型",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const model: OpenClawDefaultModel = {
|
|
primary: `${provider.id}/${config.models[0].id}`,
|
|
fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),
|
|
};
|
|
|
|
try {
|
|
await openclawApi.setDefaultModel(model);
|
|
await queryClient.invalidateQueries({
|
|
queryKey: openclawKeys.defaultModel,
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: openclawKeys.health,
|
|
});
|
|
toast.success(
|
|
t("notifications.openclawDefaultModelSet", {
|
|
defaultValue: "已设为默认模型",
|
|
}),
|
|
{ closeButton: true },
|
|
);
|
|
} catch (error) {
|
|
const detail =
|
|
extractErrorMessage(error) ||
|
|
t("notifications.openclawDefaultModelSetFailed", {
|
|
defaultValue: "设置默认模型失败",
|
|
});
|
|
toast.error(detail);
|
|
}
|
|
},
|
|
[queryClient, t],
|
|
);
|
|
|
|
return {
|
|
addProvider,
|
|
updateProvider,
|
|
switchProvider,
|
|
deleteProvider,
|
|
saveUsageScript,
|
|
setAsDefaultModel,
|
|
isLoading:
|
|
addProviderMutation.isPending ||
|
|
updateProviderMutation.isPending ||
|
|
deleteProviderMutation.isPending ||
|
|
switchProviderMutation.isPending,
|
|
};
|
|
}
|