mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
99e11e0851
* feat(codex): support native Anthropic Messages protocol as upstream Allow gateways that only expose the native Anthropic Messages protocol (/v1/messages) to be used by Codex: the local proxy performs bidirectional request/response/streaming conversion between Responses and Anthropic. Backend: - Add two conversion modules: transform_codex_anthropic / streaming_codex_anthropic - codex.rs: add routing detection and auth: ANTHROPIC_AUTH_TOKEN→Bearer (default), ANTHROPIC_API_KEY→x-api-key, mutually exclusive - handlers.rs: add handle_codex_anthropic_to_responses_transform - forwarder.rs: support optional Claude Code client fingerprint impersonation (User-Agent / anthropic-beta / x-app / system prompt first-line injection) and /responses→/v1/messages rewriting - codex_config: the anthropic format reuses the NativeResponses profile to strip custom tools - ProviderMeta: add impersonateClaudeCode Frontend: - CodexApiFormat: add "anthropic"; the form adds auth field selection and an impersonation toggle - Add en/ja/zh/zh-TW copy Robustness: - Downgrade when tool history / forced tool_choice conflicts with extended thinking, avoiding upstream 400s - Emit cache_creation_input_tokens in usage and use saturating_add to guard against overflow - Append a unique suffix to non-streaming/streaming output-item ids to avoid multi-segment text/thinking overwriting each other * fix(codex): harden Anthropic bridge against empty text blocks and truncated streams - Drop empty/whitespace-only text content blocks when rebuilding Anthropic messages from Responses history; Anthropic 400s on empty text blocks (e.g. an empty assistant text emitted alongside a tool_use), which broke follow-up and tool-result requests. Also drop messages left without content. - Do not report a truncated Anthropic stream as completed: when the SSE connection ends before message_stop with no stop_reason, emit an incomplete response if partial output exists, or a failed (stream_truncated) response otherwise, mirroring the chat converter's EOF handling. * fix(codex): resolve Anthropic-bridge review findings Blocking: 1. Defer stripping the [1m] long-context marker until after catalog matching and model write-back, re-stripping it on the final Codex→Anthropic body and setting a flag to emit the context-1m-2025-08-07 beta header, so the marker is no longer lost or overridden by the default model. 2. Gate Anthropic thinking on the trailing turn only (via trailing_turn_allows_thinking) instead of scanning full history, so a Codex session that resends history each round no longer permanently loses thinking after the first tool call. 3. Inject 5m ephemeral cache_control on the Codex→Anthropic body by reusing cache_injector (handling the system string→array conversion), so system/tools/history are cached instead of re-sent at full price every round. 4. Add a shared base_url_is_full_endpoint helper (normalizing whitespace/query/fragment/trailing slash) used by both the Anthropic and Chat paths, so a base URL already ending in /v1/messages is treated as a full endpoint instead of double-appending to /v1/messages/v1/messages. 5. Align the catalog tool-profile predicate with the routing predicate so resolve_codex_catalog_tool_profile returns the Anthropic profile whenever the request converts to Anthropic, preventing freeform tools like apply_patch from being silently filtered by a ProxyChat catalog. 6. Explicitly disable native web_search for the Anthropic profile (including the no-catalog branch) via set_codex_native_web_search_field, so Codex no longer treats it as available while the transform silently drops it. 7. Only forward tool_choice when tools survive filtering, dropping it otherwise, to avoid a non-retryable 400 from upstream when tool_choice is sent with no tools. 8. Lower the fallback default to max_tokens=8192 (only when max_output_tokens is omitted) and clamp the thinking budget to max_tokens/2, disabling thinking below the 1024 floor, to avoid hard 400s on low-output-ceiling models/gateways. Minor: 9. Centralize the Codex/OpenAI fingerprint-header denylist in is_codex_client_fingerprint_header so impersonating Claude Code uniformly drops originator/session_id/conversation_id/chatgpt-account-id/x-client-request-id/openai-* and the x-stainless-*/x-codex-* prefixes. 10. Retain content_block_start.input as start_input and fall back to it at block close when no input_json_delta arrived, so a gateway that carries the full tool input on the start event no longer yields empty tool arguments. 11. Extract a shared codex_responses_sse module as the single Responses SSE envelope builder that both the chat and anthropic streaming emitters delegate to, with byte-for-byte-unchanged wire output, so future event-format fixes touch one place instead of two. * fix(codex): add per-provider max_output_tokens override for Codex→Anthropic path Codex does not forward model_max_output_tokens in the request body, causing the proxy to fall back to a conservative 8192 default. This truncates long or thinking-heavy responses (stop_reason=max_tokens). - Add maxOutputTokens field to ProviderMeta (Rust + TypeScript) - Inject the value into the Anthropic request body before transform, taking precedence over request-supplied and default values - Add numeric input in Codex form fields (Anthropic format only) - Add i18n entries for label, placeholder, and hint (en/zh/zh-TW/ja) - Include roundtrip and omission unit tests for the new field * fix(codex): harden Anthropic protocol bridge * fix(codex): address Anthropic bridge review * fix(codex): preserve flattened Anthropic inputs * fix(codex): harden Anthropic recovery paths * fix(ci): satisfy Rust clippy --------- Co-authored-by: Jason <farion1231@gmail.com>
589 lines
18 KiB
TypeScript
589 lines
18 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
|
import type { Provider, UsageScript } from "@/types";
|
|
|
|
const toastSuccessMock = vi.fn();
|
|
const toastErrorMock = vi.fn();
|
|
const toastInfoMock = vi.fn();
|
|
const toastWarningMock = vi.fn();
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
|
info: (...args: unknown[]) => toastInfoMock(...args),
|
|
warning: (...args: unknown[]) => toastWarningMock(...args),
|
|
},
|
|
}));
|
|
|
|
const addProviderMutateAsync = vi.fn();
|
|
const updateProviderMutateAsync = vi.fn();
|
|
const deleteProviderMutateAsync = vi.fn();
|
|
const switchProviderMutateAsync = vi.fn();
|
|
|
|
const addProviderMutation = {
|
|
mutateAsync: addProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
const updateProviderMutation = {
|
|
mutateAsync: updateProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
const deleteProviderMutation = {
|
|
mutateAsync: deleteProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
const switchProviderMutation = {
|
|
mutateAsync: switchProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
|
|
const useAddProviderMutationMock = vi.fn(() => addProviderMutation);
|
|
const useUpdateProviderMutationMock = vi.fn(() => updateProviderMutation);
|
|
const useDeleteProviderMutationMock = vi.fn(() => deleteProviderMutation);
|
|
const useSwitchProviderMutationMock = vi.fn(() => switchProviderMutation);
|
|
|
|
vi.mock("@/lib/query", () => ({
|
|
useAddProviderMutation: () => useAddProviderMutationMock(),
|
|
useUpdateProviderMutation: () => useUpdateProviderMutationMock(),
|
|
useDeleteProviderMutation: () => useDeleteProviderMutationMock(),
|
|
useSwitchProviderMutation: () => useSwitchProviderMutationMock(),
|
|
}));
|
|
|
|
const providersApiUpdateMock = vi.fn();
|
|
const providersApiUpdateTrayMenuMock = vi.fn();
|
|
const settingsApiGetMock = vi.fn();
|
|
const settingsApiApplyMock = vi.fn();
|
|
const openclawApiGetModelCatalogMock = vi.fn();
|
|
const openclawApiGetDefaultModelMock = vi.fn();
|
|
const openclawApiSetDefaultModelMock = vi.fn();
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
providersApi: {
|
|
update: (...args: unknown[]) => providersApiUpdateMock(...args),
|
|
updateTrayMenu: (...args: unknown[]) =>
|
|
providersApiUpdateTrayMenuMock(...args),
|
|
},
|
|
settingsApi: {
|
|
get: (...args: unknown[]) => settingsApiGetMock(...args),
|
|
applyClaudePluginConfig: (...args: unknown[]) =>
|
|
settingsApiApplyMock(...args),
|
|
},
|
|
openclawApi: {
|
|
getModelCatalog: (...args: unknown[]) =>
|
|
openclawApiGetModelCatalogMock(...args),
|
|
getDefaultModel: (...args: unknown[]) =>
|
|
openclawApiGetDefaultModelMock(...args),
|
|
setDefaultModel: (...args: unknown[]) =>
|
|
openclawApiSetDefaultModelMock(...args),
|
|
},
|
|
}));
|
|
|
|
interface WrapperProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient();
|
|
|
|
const wrapper = ({ children }: WrapperProps) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
|
|
return { wrapper, queryClient };
|
|
}
|
|
|
|
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
|
return {
|
|
id: "provider-1",
|
|
name: "Test Provider",
|
|
settingsConfig: {},
|
|
category: "official",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
addProviderMutateAsync.mockReset();
|
|
updateProviderMutateAsync.mockReset();
|
|
deleteProviderMutateAsync.mockReset();
|
|
switchProviderMutateAsync.mockReset();
|
|
providersApiUpdateMock.mockReset();
|
|
providersApiUpdateTrayMenuMock.mockReset();
|
|
settingsApiGetMock.mockReset();
|
|
settingsApiApplyMock.mockReset();
|
|
openclawApiGetModelCatalogMock.mockReset();
|
|
openclawApiGetDefaultModelMock.mockReset();
|
|
openclawApiSetDefaultModelMock.mockReset();
|
|
toastSuccessMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
toastInfoMock.mockReset();
|
|
toastWarningMock.mockReset();
|
|
|
|
addProviderMutation.isPending = false;
|
|
updateProviderMutation.isPending = false;
|
|
deleteProviderMutation.isPending = false;
|
|
switchProviderMutation.isPending = false;
|
|
|
|
useAddProviderMutationMock.mockClear();
|
|
useUpdateProviderMutationMock.mockClear();
|
|
useDeleteProviderMutationMock.mockClear();
|
|
useSwitchProviderMutationMock.mockClear();
|
|
});
|
|
|
|
describe("useProviderActions", () => {
|
|
it("should trigger mutation when calling addProvider", async () => {
|
|
addProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
const providerInput = {
|
|
name: "New Provider",
|
|
settingsConfig: { token: "abc" },
|
|
} as Omit<Provider, "id">;
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.addProvider(providerInput);
|
|
});
|
|
|
|
expect(addProviderMutateAsync).toHaveBeenCalledTimes(1);
|
|
expect(addProviderMutateAsync).toHaveBeenCalledWith(providerInput);
|
|
});
|
|
|
|
it("should update tray menu when calling updateProvider", async () => {
|
|
updateProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
providersApiUpdateTrayMenuMock.mockResolvedValueOnce(true);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.updateProvider(provider);
|
|
});
|
|
|
|
expect(updateProviderMutateAsync).toHaveBeenCalledWith({
|
|
provider,
|
|
originalId: undefined,
|
|
});
|
|
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should not request plugin sync when switching non-Claude provider", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({ category: "custom" });
|
|
|
|
const { result } = renderHook(() => useProviderActions("codex"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
|
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
expect(toastSuccessMock).toHaveBeenCalledWith(
|
|
"切换成功,请重启客户端以生效",
|
|
{ closeButton: true },
|
|
);
|
|
});
|
|
|
|
it("warns but still switches providers that require proxy when proxy is not running", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({
|
|
category: "custom",
|
|
meta: {
|
|
apiFormat: "openai_chat",
|
|
},
|
|
});
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude", false), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(toastWarningMock).toHaveBeenCalledTimes(1);
|
|
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
|
});
|
|
|
|
it("warns but still switches Codex full URL providers when proxy is not running", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({
|
|
category: "custom",
|
|
meta: {
|
|
isFullUrl: true,
|
|
},
|
|
});
|
|
|
|
const { result } = renderHook(() => useProviderActions("codex", false), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(toastWarningMock).toHaveBeenCalledTimes(1);
|
|
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
|
});
|
|
|
|
it("warns when switching a Codex Anthropic-format provider without proxy", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({
|
|
category: "custom",
|
|
meta: { apiFormat: "anthropic" },
|
|
});
|
|
|
|
const { result } = renderHook(() => useProviderActions("codex", false), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(toastWarningMock).toHaveBeenCalledWith(
|
|
expect.stringContaining("Anthropic Messages"),
|
|
);
|
|
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
|
});
|
|
|
|
it("should sync plugin config when switching Claude provider with integration enabled", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: true,
|
|
});
|
|
settingsApiApplyMock.mockResolvedValueOnce(true);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({ category: "official" });
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
|
expect(settingsApiGetMock).toHaveBeenCalledTimes(1);
|
|
expect(settingsApiApplyMock).toHaveBeenCalledWith({ official: true });
|
|
});
|
|
|
|
it("should not call applyClaudePluginConfig when integration is disabled", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: false,
|
|
});
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(settingsApiGetMock).toHaveBeenCalledTimes(1);
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should show error toast when plugin sync fails with error message", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: true,
|
|
});
|
|
settingsApiApplyMock.mockRejectedValueOnce(new Error("Sync failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("Sync failed");
|
|
});
|
|
|
|
it("propagates updateProvider errors", async () => {
|
|
updateProviderMutateAsync.mockRejectedValueOnce(new Error("update failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
act(async () => {
|
|
await result.current.updateProvider(provider);
|
|
}),
|
|
).rejects.toThrow("update failed");
|
|
});
|
|
|
|
it("should use default error message when plugin sync fails without error message", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: true,
|
|
});
|
|
settingsApiApplyMock.mockRejectedValueOnce(new Error(""));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("同步 Claude 插件失败");
|
|
});
|
|
|
|
it("handles mutation errors when plugin sync is skipped", async () => {
|
|
switchProviderMutateAsync.mockRejectedValueOnce(new Error("switch failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("codex"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
result.current.switchProvider(provider),
|
|
).resolves.toBeUndefined();
|
|
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call delete mutation when calling deleteProvider", async () => {
|
|
deleteProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.deleteProvider("provider-2");
|
|
});
|
|
|
|
expect(deleteProviderMutateAsync).toHaveBeenCalledWith("provider-2");
|
|
});
|
|
|
|
it("should update provider and refresh cache when saveUsageScript succeeds", async () => {
|
|
providersApiUpdateMock.mockResolvedValueOnce(true);
|
|
const { wrapper, queryClient } = createWrapper();
|
|
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
|
|
|
const provider = createProvider({
|
|
meta: {
|
|
usage_script: {
|
|
enabled: false,
|
|
language: "javascript",
|
|
code: "",
|
|
},
|
|
},
|
|
});
|
|
|
|
const script: UsageScript = {
|
|
enabled: true,
|
|
language: "javascript",
|
|
code: "return { success: true };",
|
|
timeout: 5,
|
|
};
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveUsageScript(provider, script);
|
|
});
|
|
|
|
expect(providersApiUpdateMock).toHaveBeenCalledWith(
|
|
{
|
|
...provider,
|
|
meta: {
|
|
...provider.meta,
|
|
usage_script: script,
|
|
},
|
|
},
|
|
"claude",
|
|
);
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
queryKey: ["providers", "claude"],
|
|
});
|
|
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should show error toast when saveUsageScript fails with error message", async () => {
|
|
providersApiUpdateMock.mockRejectedValueOnce(new Error("Save failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
const script: UsageScript = {
|
|
enabled: true,
|
|
language: "javascript",
|
|
code: "return {}",
|
|
};
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveUsageScript(provider, script);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("Save failed");
|
|
});
|
|
|
|
it("should use default error message when saveUsageScript fails without error message", async () => {
|
|
providersApiUpdateMock.mockRejectedValueOnce(new Error(""));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
const script: UsageScript = {
|
|
enabled: true,
|
|
language: "javascript",
|
|
code: "return {}",
|
|
};
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveUsageScript(provider, script);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("用量查询配置保存失败");
|
|
});
|
|
|
|
it("propagates addProvider errors to caller", async () => {
|
|
addProviderMutateAsync.mockRejectedValueOnce(new Error("add failed"));
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
act(async () => {
|
|
await result.current.addProvider({
|
|
name: "temp",
|
|
settingsConfig: {},
|
|
} as Omit<Provider, "id">);
|
|
}),
|
|
).rejects.toThrow("add failed");
|
|
});
|
|
|
|
it("propagates deleteProvider errors to caller", async () => {
|
|
deleteProviderMutateAsync.mockRejectedValueOnce(new Error("delete failed"));
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
act(async () => {
|
|
await result.current.deleteProvider("provider-2");
|
|
}),
|
|
).rejects.toThrow("delete failed");
|
|
});
|
|
|
|
it("handles switch mutation errors silently", async () => {
|
|
switchProviderMutateAsync.mockRejectedValueOnce(new Error("switch failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await result.current.switchProvider(provider);
|
|
|
|
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should track pending state of all mutations in isLoading", () => {
|
|
addProviderMutation.isPending = true;
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(true);
|
|
});
|
|
|
|
it("does not show backup details when setting OpenClaw default model", async () => {
|
|
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
|
|
backupPath: "/tmp/openclaw-backup.json5",
|
|
warnings: [],
|
|
});
|
|
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({
|
|
settingsConfig: {
|
|
models: [{ id: "gpt-4.1" }, { id: "gpt-4.1-mini" }],
|
|
},
|
|
});
|
|
|
|
const { result } = renderHook(() => useProviderActions("openclaw"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.setAsDefaultModel(provider);
|
|
});
|
|
|
|
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
|
|
primary: "provider-1/gpt-4.1",
|
|
fallbacks: ["provider-1/gpt-4.1-mini"],
|
|
});
|
|
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
|
|
expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: true });
|
|
});
|
|
});
|
|
it("clears loading flag when all mutations idle", () => {
|
|
addProviderMutation.isPending = false;
|
|
updateProviderMutation.isPending = false;
|
|
deleteProviderMutation.isPending = false;
|
|
switchProviderMutation.isPending = false;
|
|
|
|
const { wrapper } = createWrapper();
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|