Files
CC-Switch/tests/hooks/useProviderActions.test.tsx
T
Yeeyzy 99e11e0851 feat(codex): support native Anthropic Messages protocol as upstream (#5071)
* 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>
2026-07-10 23:06:30 +08:00

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