mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Merge main and harden managed Codex account flow
This commit is contained in:
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||
import type { ProviderFormValues } from "@/components/providers/forms/ProviderForm";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -125,4 +126,96 @@ describe("AddProviderDialog", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("submits the managed account selected from the Codex Official preset", async () => {
|
||||
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const officialPresetIndex = codexProviderPresets.findIndex(
|
||||
(preset) =>
|
||||
preset.category === "official" && preset.providerType === "codex_oauth",
|
||||
);
|
||||
expect(officialPresetIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
mockFormValues = {
|
||||
name: "OpenAI Official",
|
||||
websiteUrl: "https://chatgpt.com/codex",
|
||||
settingsConfig: JSON.stringify({ auth: {}, config: "" }),
|
||||
presetId: `codex-${officialPresetIndex}`,
|
||||
presetCategory: "official",
|
||||
meta: {
|
||||
providerType: "codex_oauth",
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
appId="codex"
|
||||
onSubmit={handleSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
|
||||
|
||||
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(handleSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
category: "official",
|
||||
ensureCodexOfficialSeed: true,
|
||||
meta: expect.objectContaining({
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("新建 Grok Build 自定义供应商时不补默认 Grok 图标", async () => {
|
||||
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockFormValues = {
|
||||
name: "tes 1",
|
||||
websiteUrl: "",
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
settingsConfig: JSON.stringify({
|
||||
config: `[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://grok.example.com/v1"
|
||||
name = "tes 1"
|
||||
api_key = "secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
appId="grokbuild"
|
||||
onSubmit={handleSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
|
||||
|
||||
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
||||
|
||||
const submitted = handleSubmit.mock.calls[0][0];
|
||||
expect(submitted.icon).toBeUndefined();
|
||||
expect(submitted.iconColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
// claude-old 迁移到 Sonnet;留空的 Opus / Fable / Haiku 回填为 Sonnet 的
|
||||
// 上游模型,保证落库四档齐全,子 agent 调用的各档始终可解析。
|
||||
expect(submitted.meta.claudeDesktopModelRoutes).toMatchObject({
|
||||
"claude-sonnet-4-6": {
|
||||
"claude-sonnet-5": {
|
||||
model: "upstream-old",
|
||||
labelOverride: "upstream-old",
|
||||
},
|
||||
@@ -186,7 +186,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
"claude-fable-5",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-5",
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -217,7 +217,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const routes = onSubmit.mock.calls[0][0].meta.claudeDesktopModelRoutes;
|
||||
// 留空的 Opus / Haiku 回填同一上游模型,1M 声明应与 Sonnet 一致。
|
||||
expect(routes["claude-sonnet-4-6"]).toMatchObject({
|
||||
expect(routes["claude-sonnet-5"]).toMatchObject({
|
||||
model: "deepseek-v4-pro",
|
||||
supports1m: true,
|
||||
});
|
||||
@@ -259,8 +259,8 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const submitted = onSubmit.mock.calls[0][0];
|
||||
expect(submitted.meta.claudeDesktopModelRoutes).toMatchObject({
|
||||
"claude-sonnet-4-6": {
|
||||
model: "claude-sonnet-4-6",
|
||||
"claude-sonnet-5": {
|
||||
model: "claude-sonnet-5",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
|
||||
defaultOpusModelName: "",
|
||||
defaultFableModel: "",
|
||||
defaultFableModelName: "",
|
||||
subagentModel: "",
|
||||
onModelChange: vi.fn(),
|
||||
speedTestEndpoints: [],
|
||||
apiFormat: "anthropic",
|
||||
@@ -173,4 +174,25 @@ describe("ClaudeFormFields", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("一键设置会同时写入 Subagent 模型", () => {
|
||||
const onModelChange = vi.fn();
|
||||
renderCopilotForm({
|
||||
claudeModel: "shared-model[1M]",
|
||||
defaultSonnetModel: "",
|
||||
defaultSonnetModelName: "",
|
||||
onModelChange,
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "一键设置",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onModelChange).toHaveBeenCalledWith(
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
"shared-model[1M]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
|
||||
const Panels = ({ innerOpen }: { innerOpen: boolean }) => (
|
||||
<>
|
||||
<FullScreenPanel isOpen title="Outer" onClose={() => undefined}>
|
||||
outer
|
||||
</FullScreenPanel>
|
||||
<FullScreenPanel isOpen={innerOpen} title="Inner" onClose={() => undefined}>
|
||||
inner
|
||||
</FullScreenPanel>
|
||||
</>
|
||||
);
|
||||
|
||||
describe("FullScreenPanel body scroll locking", () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
it("keeps the body locked when a nested panel closes", () => {
|
||||
document.body.style.overflow = "clip";
|
||||
const view = render(<Panels innerOpen />);
|
||||
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
|
||||
view.rerender(<Panels innerOpen={false} />);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
|
||||
view.unmount();
|
||||
expect(document.body.style.overflow).toBe("clip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GrokBuildProviderForm } from "@/components/providers/forms/GrokBuildProviderForm";
|
||||
|
||||
vi.mock("@/components/JsonEditor", () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="raw-config"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("GrokBuildProviderForm", () => {
|
||||
it("offers Codex-compatible provider presets and applies one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PatewayAI/ }));
|
||||
|
||||
const baseUrlInput =
|
||||
container.querySelector<HTMLInputElement>("#codexBaseUrl");
|
||||
const nameInput =
|
||||
container.querySelector<HTMLInputElement>('input[name="name"]');
|
||||
expect(baseUrlInput?.value).toBe("https://api.pateway.ai/v1");
|
||||
expect(nameInput?.value).toBe("PatewayAI");
|
||||
});
|
||||
|
||||
it("submits a complete config.toml payload with Grok defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nameInput =
|
||||
container.querySelector<HTMLInputElement>('input[name="name"]');
|
||||
const baseUrlInput =
|
||||
container.querySelector<HTMLInputElement>("#codexBaseUrl");
|
||||
expect(nameInput).not.toBeNull();
|
||||
expect(baseUrlInput).not.toBeNull();
|
||||
|
||||
fireEvent.change(nameInput!, { target: { value: "Example Relay" } });
|
||||
fireEvent.change(baseUrlInput!, {
|
||||
target: { value: "https://relay.example.com/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("API Key"), {
|
||||
target: { value: "secret-key" },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const submitted = onSubmit.mock.calls[0][0];
|
||||
expect(submitted.icon).toBe("");
|
||||
const settings = JSON.parse(submitted.settingsConfig);
|
||||
const config = parseToml(settings.config) as any;
|
||||
|
||||
expect(config.models.default).toBe("grok-4.5");
|
||||
expect(config.model["grok-4.5"]).toEqual({
|
||||
model: "grok-4.5",
|
||||
base_url: "https://relay.example.com/v1",
|
||||
name: "Example Relay",
|
||||
api_key: "secret-key",
|
||||
api_backend: "responses",
|
||||
context_window: 500000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Chat Completions presets into Grok api_backend", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /BytePlus/ }));
|
||||
await user.type(screen.getByLabelText("API Key"), "secret-key");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const submitted = onSubmit.mock.calls[0][0];
|
||||
const settings = JSON.parse(submitted.settingsConfig);
|
||||
const config = parseToml(settings.config) as any;
|
||||
expect(submitted.meta.apiFormat).toBe("openai_chat");
|
||||
expect(config.model[config.models.default].api_backend).toBe(
|
||||
"chat_completions",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders localized validation feedback for malformed TOML", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("raw-config"), {
|
||||
target: { value: "[models" },
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Invalid config\.toml:/)).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads edit-mode values and does not resubmit stale custom endpoints", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const config = `[models]
|
||||
default = "existing-profile"
|
||||
|
||||
[model."existing-profile"]
|
||||
model = "grok-upstream"
|
||||
base_url = "https://existing.example.com/v1"
|
||||
name = "Existing Relay"
|
||||
api_key = "existing-key"
|
||||
api_backend = "responses"
|
||||
context_window = 250000
|
||||
`;
|
||||
const { container } = render(
|
||||
<GrokBuildProviderForm
|
||||
providerId="existing-provider"
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
initialData={{
|
||||
name: "Existing Relay",
|
||||
settingsConfig: { config },
|
||||
meta: {
|
||||
custom_endpoints: {
|
||||
"https://deleted.example.com/v1": {
|
||||
url: "https://deleted.example.com/v1",
|
||||
addedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("#grokbuild-profile")?.value,
|
||||
).toBe("existing-profile");
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("#codexBaseUrl")?.value,
|
||||
).toBe("https://existing.example.com/v1");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0][0].meta.custom_endpoints).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
|
||||
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
refetchCodex: vi.fn(),
|
||||
refetchCopilot: vi.fn(),
|
||||
}));
|
||||
|
||||
const failedStatus = (refetchStatus: () => void) => ({
|
||||
accounts: [],
|
||||
defaultAccountId: null,
|
||||
migrationError: null,
|
||||
isStatusSuccess: false,
|
||||
isStatusError: true,
|
||||
hasAnyAccount: false,
|
||||
pollingState: "idle" as const,
|
||||
deviceCode: null,
|
||||
error: null,
|
||||
isPolling: false,
|
||||
isAddingAccount: false,
|
||||
isRemovingAccount: false,
|
||||
isSettingDefaultAccount: false,
|
||||
addAccount: vi.fn(),
|
||||
removeAccount: vi.fn(),
|
||||
setDefaultAccount: vi.fn(),
|
||||
cancelAuth: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refetchStatus,
|
||||
});
|
||||
|
||||
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
|
||||
useCodexOauth: () => failedStatus(authMocks.refetchCodex),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/hooks/useCopilotAuth", () => ({
|
||||
useCopilotAuth: () => failedStatus(authMocks.refetchCopilot),
|
||||
}));
|
||||
|
||||
describe("managed auth status failures", () => {
|
||||
beforeEach(() => {
|
||||
authMocks.refetchCodex.mockResolvedValue(undefined);
|
||||
authMocks.refetchCopilot.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("shows a retryable error instead of an empty Codex account selector", () => {
|
||||
const onAccountSelect = vi.fn();
|
||||
render(
|
||||
<CodexOAuthSection
|
||||
mode="select"
|
||||
selectedAccountId="acct-existing"
|
||||
onAccountSelect={onAccountSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"无法加载 ChatGPT 账号状态,请重试。",
|
||||
);
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
expect(onAccountSelect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(authMocks.refetchCodex).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a retryable error instead of an empty Copilot account selector", () => {
|
||||
const onAccountSelect = vi.fn();
|
||||
render(
|
||||
<CopilotAuthSection
|
||||
mode="select"
|
||||
selectedAccountId="acct-existing"
|
||||
onAccountSelect={onAccountSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"无法加载 GitHub Copilot 账号状态,请重试。",
|
||||
);
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
expect(onAccountSelect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(authMocks.refetchCopilot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -245,6 +245,7 @@ describe("McpFormModal", () => {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
},
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -390,6 +391,7 @@ type = "stdio"
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).toHaveBeenCalledWith();
|
||||
@@ -423,6 +425,12 @@ type = "stdio"
|
||||
expect(geminiCheckbox.checked).toBe(true);
|
||||
fireEvent.click(geminiCheckbox);
|
||||
|
||||
const grokbuildCheckbox = screen.getByLabelText(
|
||||
"mcp.unifiedPanel.apps.grokbuild",
|
||||
) as HTMLInputElement;
|
||||
expect(grokbuildCheckbox.checked).toBe(true);
|
||||
fireEvent.click(grokbuildCheckbox);
|
||||
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||
@@ -432,6 +440,7 @@ type = "stdio"
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OpenCodeFormFields } from "@/components/providers/forms/OpenCodeFormFields";
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
type OpenCodeFormFieldsProps = ComponentProps<typeof OpenCodeFormFields>;
|
||||
|
||||
const FormShell = ({ children }: PropsWithChildren) => {
|
||||
const form = useForm();
|
||||
|
||||
return <Form {...form}>{children}</Form>;
|
||||
};
|
||||
|
||||
const renderOpenCodeForm = (
|
||||
overrides: Partial<OpenCodeFormFieldsProps> = {},
|
||||
) => {
|
||||
const props: OpenCodeFormFieldsProps = {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
onNpmChange: vi.fn(),
|
||||
apiKey: "sk-test",
|
||||
onApiKeyChange: vi.fn(),
|
||||
category: "custom",
|
||||
shouldShowApiKeyLink: false,
|
||||
websiteUrl: "",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
onBaseUrlChange: vi.fn(),
|
||||
headers: {},
|
||||
onHeadersChange: vi.fn(),
|
||||
models: {
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
limit: { context: 1048576, output: 131072 },
|
||||
},
|
||||
},
|
||||
onModelsChange: vi.fn(),
|
||||
extraOptions: {},
|
||||
onExtraOptionsChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
props,
|
||||
...render(
|
||||
<FormShell>
|
||||
<OpenCodeFormFields {...props} />
|
||||
</FormShell>,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const expandFirstModel = () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle model details" }));
|
||||
};
|
||||
|
||||
describe("OpenCodeFormFields", () => {
|
||||
it("surfaces existing provider headers", () => {
|
||||
renderOpenCodeForm({
|
||||
headers: {
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("HTTP-Referer")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue("https://cc-switch.app"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("X-Title")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("CC Switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates provider headers", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
renderOpenCodeForm({
|
||||
headers: { "X-Title": "CC Switch" },
|
||||
onHeadersChange,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("CC Switch"), {
|
||||
target: { value: "OpenCode" },
|
||||
});
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalledWith({
|
||||
"X-Title": "OpenCode",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a blank header name for newly added headers", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
const { rerender, props } = renderOpenCodeForm({ onHeadersChange });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add header" }));
|
||||
|
||||
const nextHeaders = onHeadersChange.mock.calls[0][0];
|
||||
const headerKey = Object.keys(nextHeaders)[0];
|
||||
expect(headerKey).toMatch(/^draft-header:/);
|
||||
|
||||
rerender(
|
||||
<FormShell>
|
||||
<OpenCodeFormFields {...props} headers={nextHeaders} />
|
||||
</FormShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("X-Title")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("removes provider headers", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
renderOpenCodeForm({
|
||||
headers: { "X-Title": "CC Switch" },
|
||||
onHeadersChange,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove header" }));
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("rejects case-insensitive duplicate header names and restores the input", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
renderOpenCodeForm({
|
||||
headers: { "X-A": "A", "X-B": "B" },
|
||||
onHeadersChange,
|
||||
});
|
||||
|
||||
const keyInput = screen.getByDisplayValue("X-B");
|
||||
fireEvent.change(keyInput, { target: { value: "x-a" } });
|
||||
fireEvent.blur(keyInput);
|
||||
|
||||
expect(onHeadersChange).not.toHaveBeenCalled();
|
||||
expect(keyInput).toHaveValue("X-B");
|
||||
});
|
||||
|
||||
it("surfaces provider options whose names start with option-", () => {
|
||||
renderOpenCodeForm({
|
||||
extraOptions: { "option-mode": "legacy" },
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("option-mode")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("legacy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces existing model token limits", () => {
|
||||
renderOpenCodeForm();
|
||||
|
||||
expandFirstModel();
|
||||
|
||||
expect(screen.getByLabelText("Context")).toHaveValue(1048576);
|
||||
expect(screen.getByLabelText("Output")).toHaveValue(131072);
|
||||
});
|
||||
|
||||
it("updates model token limits as structured numbers", () => {
|
||||
const onModelsChange = vi.fn();
|
||||
renderOpenCodeForm({ onModelsChange });
|
||||
|
||||
expandFirstModel();
|
||||
fireEvent.change(screen.getByLabelText("Context"), {
|
||||
target: { value: "2000000" },
|
||||
});
|
||||
|
||||
expect(onModelsChange).toHaveBeenCalledWith({
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
limit: { context: 2000000, output: 131072 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes model limit when both fields are cleared", () => {
|
||||
const onModelsChange = vi.fn();
|
||||
const { rerender, props } = renderOpenCodeForm({ onModelsChange });
|
||||
|
||||
expandFirstModel();
|
||||
fireEvent.change(screen.getByLabelText("Context"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
|
||||
const withoutContext = {
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
limit: { output: 131072 },
|
||||
},
|
||||
};
|
||||
expect(onModelsChange).toHaveBeenLastCalledWith(withoutContext);
|
||||
|
||||
rerender(
|
||||
<FormShell>
|
||||
<OpenCodeFormFields {...props} models={withoutContext} />
|
||||
</FormShell>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Output"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
|
||||
expect(onModelsChange).toHaveBeenLastCalledWith({
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { createTestQueryClient } from "../utils/testQueryClient";
|
||||
|
||||
vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
|
||||
CodexOAuthSection: ({
|
||||
onAccountSelect,
|
||||
}: {
|
||||
onAccountSelect?: (accountId: string | null) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onAccountSelect?.("acct-managed")}>
|
||||
select-managed-account
|
||||
</button>
|
||||
<button type="button" onClick={() => onAccountSelect?.(null)}>
|
||||
select-native-login
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/CodexConfigEditor", () => ({
|
||||
default: () => <div data-testid="codex-config-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/ProviderAdvancedConfig", () => ({
|
||||
ProviderAdvancedConfig: () => <div data-testid="advanced-config" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/hooks", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/components/providers/forms/hooks")>();
|
||||
return {
|
||||
...actual,
|
||||
useCopilotAuth: () => ({
|
||||
isAuthenticated: false,
|
||||
isStatusSuccess: true,
|
||||
isStatusError: false,
|
||||
}),
|
||||
useCodexOauth: () => ({
|
||||
isAuthenticated: true,
|
||||
isStatusSuccess: true,
|
||||
isStatusError: false,
|
||||
}),
|
||||
useCodexCommonConfig: () => ({
|
||||
useCommonConfig: false,
|
||||
commonConfigSnippet: "",
|
||||
commonConfigError: null,
|
||||
handleCommonConfigToggle: vi.fn(),
|
||||
handleCommonConfigSnippetChange: vi.fn(),
|
||||
isExtracting: false,
|
||||
handleExtract: vi.fn(),
|
||||
clearCommonConfigError: vi.fn(),
|
||||
}),
|
||||
useGeminiCommonConfig: () => ({
|
||||
useCommonConfig: false,
|
||||
commonConfigSnippet: "",
|
||||
commonConfigError: null,
|
||||
handleCommonConfigToggle: vi.fn(),
|
||||
handleCommonConfigSnippetChange: vi.fn(),
|
||||
isExtracting: false,
|
||||
handleExtract: vi.fn(),
|
||||
clearCommonConfigError: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/query")>();
|
||||
return {
|
||||
...actual,
|
||||
useSettingsQuery: () => ({
|
||||
data: { commonConfigConfirmed: true },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function renderCodexForm(onSubmit: (values: ProviderFormValues) => void) {
|
||||
const queryClient = createTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProviderForm
|
||||
appId="codex"
|
||||
submitLabel="save-provider"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ProviderForm Codex Official managed account", () => {
|
||||
it("persists the selected managed account while stripping OAuth secrets", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderCodexForm(onSubmit);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "select-managed-account" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
|
||||
expect(submitted).toEqual(
|
||||
expect.objectContaining({
|
||||
presetId: "codex-0",
|
||||
presetCategory: "official",
|
||||
meta: expect.objectContaining({
|
||||
providerType: "codex_oauth",
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(submitted.settingsConfig)).toEqual({
|
||||
auth: {},
|
||||
config: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Official on native browser login when no managed account is selected", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderCodexForm(onSubmit);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "select-managed-account" }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "select-native-login" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
|
||||
expect(submitted.presetId).toBe("codex-0");
|
||||
expect(submitted.presetCategory).toBe("official");
|
||||
expect(submitted.meta?.providerType).toBeUndefined();
|
||||
expect(submitted.meta?.authBinding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -51,8 +51,8 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
description: "Imported from Grok Build",
|
||||
foundIn: ["grokbuild"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
@@ -82,8 +82,8 @@ describe("UnifiedSkillsPanel", () => {
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
description: "Imported from Grok Build",
|
||||
foundIn: ["grokbuild"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
@@ -116,5 +116,18 @@ describe("UnifiedSkillsPanel", () => {
|
||||
expect(screen.getByText("Shared Skill")).toBeInTheDocument();
|
||||
expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("skills.importSelected").click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importSkillsMock).toHaveBeenCalledWith([
|
||||
{
|
||||
directory: "shared-skill",
|
||||
apps: expect.objectContaining({ grokbuild: true }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
|
||||
const useProviderStatsMock = vi.hoisted(() => vi.fn());
|
||||
const useModelStatsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
i18n: {
|
||||
resolvedLanguage: "en",
|
||||
language: "en",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("framer-motion", () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useUsageEventBridge", () => ({
|
||||
useUsageEventBridge: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/query/usage", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/query/usage")>(
|
||||
"@/lib/query/usage",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useProviderStats: (...args: unknown[]) => useProviderStatsMock(...args),
|
||||
useModelStats: (...args: unknown[]) => useModelStatsMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/usage/UsageHero", () => ({
|
||||
UsageHero: () => <div data-testid="usage-hero" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/UsageTrendChart", () => ({
|
||||
UsageTrendChart: () => <div data-testid="usage-trend" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/RequestLogTable", () => ({
|
||||
RequestLogTable: () => <div data-testid="request-log-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/ProviderStatsTable", () => ({
|
||||
ProviderStatsTable: () => <div data-testid="provider-stats-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/ModelStatsTable", () => ({
|
||||
ModelStatsTable: () => <div data-testid="model-stats-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/PricingConfigPanel", () => ({
|
||||
PricingConfigPanel: () => <div data-testid="pricing-config-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/UsageDateRangePicker", () => ({
|
||||
UsageDateRangePicker: () => <button type="button">date-range</button>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ value, onValueChange, children }: any) => (
|
||||
<div data-testid={`select-${value}`}>
|
||||
{children}
|
||||
<button type="button" onClick={() => onValueChange?.("5000")}>
|
||||
choose-5000
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children, ...props }: any) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: any) => <div>{children}</div>,
|
||||
SelectItem: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
const renderDashboard = (props: ComponentProps<typeof UsageDashboard> = {}) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UsageDashboard {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("UsageDashboard", () => {
|
||||
beforeEach(() => {
|
||||
useProviderStatsMock.mockReset();
|
||||
useModelStatsMock.mockReset();
|
||||
useProviderStatsMock.mockReturnValue({ data: [] });
|
||||
useModelStatsMock.mockReturnValue({ data: [] });
|
||||
});
|
||||
|
||||
it("uses the saved refresh interval when mounted", () => {
|
||||
renderDashboard({ refreshIntervalMs: 5000 });
|
||||
|
||||
expect(screen.getByTestId("select-5000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists refresh interval changes", async () => {
|
||||
const onRefreshIntervalChange = vi.fn().mockResolvedValue(true);
|
||||
renderDashboard({ onRefreshIntervalChange });
|
||||
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("select-30000")).getByRole("button", {
|
||||
name: "choose-5000",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
|
||||
);
|
||||
expect(screen.getByTestId("select-5000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rolls back optimistic interval changes when persistence fails", async () => {
|
||||
const onRefreshIntervalChange = vi.fn().mockResolvedValue(false);
|
||||
renderDashboard({ onRefreshIntervalChange });
|
||||
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("select-30000")).getByRole("button", {
|
||||
name: "choose-5000",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("select-30000")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,21 +10,40 @@ describe("Kimi For Coding Provider Preset", () => {
|
||||
expect(kimiForCoding).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use template placeholder for Claude Code auto-compact window", () => {
|
||||
// CLAUDE_CODE_MAX_CONTEXT_TOKENS is ignored for claude-* model ids, so the
|
||||
// preset must route the endpoint's own alias for the context envs to bite
|
||||
it("should route the kimi-for-coding model id on every tier", () => {
|
||||
const env = (kimiForCoding!.settingsConfig as any).env;
|
||||
expect(env).toHaveProperty(
|
||||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
||||
"${CLAUDE_CODE_AUTO_COMPACT_WINDOW}",
|
||||
);
|
||||
expect(env).toMatchObject({
|
||||
ANTHROPIC_MODEL: "kimi-for-coding",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-for-coding",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-for-coding",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-for-coding",
|
||||
});
|
||||
});
|
||||
|
||||
it("should expose auto-compact window as editable template value with Kimi default", () => {
|
||||
const values = (kimiForCoding!.templateValues as any)
|
||||
?.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
expect(values).toBeDefined();
|
||||
expect(values.defaultValue).toBe("262144");
|
||||
expect(values.editorValue).toBe("262144");
|
||||
expect(values.label).toBe("Auto Compact Window");
|
||||
// 预设直接钉值,不再暴露表单输入框;要调整的用户直接改 JSON 编辑框
|
||||
it("should pin the 256K context envs without exposing form fields", () => {
|
||||
const env = (kimiForCoding!.settingsConfig as any).env;
|
||||
expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("262144");
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("262144");
|
||||
expect(kimiForCoding!.templateValues).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex Provider Preset", () => {
|
||||
const codex = providerPresets.find((p) => p.name === "Codex");
|
||||
|
||||
it("should include the Codex preset", () => {
|
||||
expect(codex).toBeDefined();
|
||||
});
|
||||
|
||||
// 预设直接钉 Codex 目录的 372K 窗口(openai/codex#31860),不暴露表单输入框
|
||||
it("should pin the Codex-catalog 372K window without exposing form fields", () => {
|
||||
const env = (codex!.settingsConfig as any).env;
|
||||
expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("372000");
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("372000");
|
||||
expect(codex!.templateValues).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,14 @@ const expectedChatPresets = new Map<
|
||||
]);
|
||||
|
||||
describe("Codex Chat provider presets", () => {
|
||||
it("enables session-based prompt cache routing for Kimi Coding", () => {
|
||||
const preset = codexProviderPresets.find(
|
||||
(item) => item.name === "Kimi For Coding",
|
||||
);
|
||||
|
||||
expect(preset?.promptCacheRouting).toBe("enabled");
|
||||
});
|
||||
|
||||
it("marks migrated Chat Completions presets for local routing", () => {
|
||||
for (const [name, expected] of expectedChatPresets) {
|
||||
const preset = codexProviderPresets.find((item) => item.name === name);
|
||||
@@ -163,7 +171,7 @@ describe("Codex Chat provider presets", () => {
|
||||
{ contextWindows: { "doubao-seed-2-1-pro-260628": 262144 } },
|
||||
],
|
||||
["Bailian", { contextWindows: { "qwen3-coder-plus": 1048576 } }],
|
||||
["Longcat", { contextWindows: { "LongCat-2.0-Preview": 1048576 } }],
|
||||
["Longcat", { contextWindows: { "LongCat-2.0": 1048576 } }],
|
||||
["MiniMax", { contextWindows: { "MiniMax-M3": 1000000 } }],
|
||||
["MiniMax en", { contextWindows: { "MiniMax-M3": 1000000 } }],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { hermesProviderPresets } from "@/config/hermesProviderPresets";
|
||||
import { openclawProviderPresets } from "@/config/openclawProviderPresets";
|
||||
import { opencodeProviderPresets } from "@/config/opencodeProviderPresets";
|
||||
|
||||
const LONGCAT_MODEL = "LongCat-2.0";
|
||||
const LONGCAT_DISPLAY_NAME = "LongCat 2.0";
|
||||
const LONGCAT_OPENAI_BASE_URL = "https://api.longcat.chat/openai/v1";
|
||||
const LONGCAT_BRAND = "LongCat";
|
||||
const FLASH_VARIANT = "Flash";
|
||||
const CHAT_SUFFIX = "Chat";
|
||||
const REMOVED_LONGCAT_NAMES = [
|
||||
`${LONGCAT_BRAND}-${FLASH_VARIANT}-${CHAT_SUFFIX}`,
|
||||
`${LONGCAT_BRAND} ${FLASH_VARIANT} ${CHAT_SUFFIX}`,
|
||||
`${LONGCAT_MODEL}-Preview`,
|
||||
`${LONGCAT_DISPLAY_NAME} Preview`,
|
||||
];
|
||||
|
||||
function findLongcatPreset<T extends { name: string }>(presets: T[]): T {
|
||||
const preset = presets.find((item) => item.name === "Longcat");
|
||||
|
||||
expect(preset).toBeDefined();
|
||||
return preset!;
|
||||
}
|
||||
|
||||
describe("Longcat provider presets", () => {
|
||||
it("uses the official LongCat 2.0 model for Claude Code", () => {
|
||||
const preset = findLongcatPreset(providerPresets);
|
||||
const env = (preset.settingsConfig as { env: Record<string, unknown> }).env;
|
||||
|
||||
expect(env).toMatchObject({
|
||||
ANTHROPIC_MODEL: LONGCAT_MODEL,
|
||||
ANTHROPIC_SMALL_FAST_MODEL: LONGCAT_MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: LONGCAT_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: LONGCAT_MODEL,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: LONGCAT_MODEL,
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "131072",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the official LongCat 2.0 model for Claude Desktop routes", () => {
|
||||
const preset = findLongcatPreset(claudeDesktopProviderPresets);
|
||||
|
||||
expect(
|
||||
preset.modelRoutes?.map((route) => ({
|
||||
upstreamModel: route.upstreamModel,
|
||||
labelOverride: route.labelOverride,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
upstreamModel: LONGCAT_MODEL,
|
||||
labelOverride: LONGCAT_MODEL,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the official LongCat 2.0 model for Hermes", () => {
|
||||
const preset = findLongcatPreset(hermesProviderPresets);
|
||||
|
||||
expect(preset.settingsConfig.models).toEqual([
|
||||
{ id: LONGCAT_MODEL, name: LONGCAT_DISPLAY_NAME },
|
||||
]);
|
||||
expect(preset.suggestedDefaults?.model).toEqual({
|
||||
default: LONGCAT_MODEL,
|
||||
provider: "longcat",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the official LongCat 2.0 model for OpenCode", () => {
|
||||
const preset = findLongcatPreset(opencodeProviderPresets);
|
||||
|
||||
expect(preset.settingsConfig.options?.baseURL).toBe(
|
||||
LONGCAT_OPENAI_BASE_URL,
|
||||
);
|
||||
expect(preset.templateValues?.baseURL.defaultValue).toBe(
|
||||
LONGCAT_OPENAI_BASE_URL,
|
||||
);
|
||||
expect(preset.templateValues?.baseURL.placeholder).toBe(
|
||||
LONGCAT_OPENAI_BASE_URL,
|
||||
);
|
||||
expect(preset.settingsConfig.models).toEqual({
|
||||
[LONGCAT_MODEL]: {
|
||||
name: LONGCAT_DISPLAY_NAME,
|
||||
options: { thinking: { type: "disabled" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the official LongCat 2.0 model for OpenClaw", () => {
|
||||
const preset = findLongcatPreset(openclawProviderPresets);
|
||||
|
||||
expect(preset.settingsConfig.baseUrl).toBe(LONGCAT_OPENAI_BASE_URL);
|
||||
expect(preset.templateValues?.baseUrl.defaultValue).toBe(
|
||||
LONGCAT_OPENAI_BASE_URL,
|
||||
);
|
||||
expect(preset.templateValues?.baseUrl.placeholder).toBe(
|
||||
LONGCAT_OPENAI_BASE_URL,
|
||||
);
|
||||
expect(preset.settingsConfig.models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: LONGCAT_MODEL,
|
||||
name: LONGCAT_DISPLAY_NAME,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 131072,
|
||||
compat: { maxTokensField: "max_tokens" },
|
||||
}),
|
||||
]);
|
||||
expect(preset.suggestedDefaults?.model).toEqual({
|
||||
primary: `longcat/${LONGCAT_MODEL}`,
|
||||
});
|
||||
expect(preset.suggestedDefaults?.modelCatalog).toEqual({
|
||||
[`longcat/${LONGCAT_MODEL}`]: { alias: "LongCat" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the official LongCat 2.0 model for Codex", () => {
|
||||
const preset = findLongcatPreset(codexProviderPresets);
|
||||
|
||||
expect(preset.config).toContain(`model = "${LONGCAT_MODEL}"`);
|
||||
expect(preset.modelCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
model: LONGCAT_MODEL,
|
||||
displayName: LONGCAT_DISPLAY_NAME,
|
||||
contextWindow: 1048576,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not keep retired or preview Longcat model names", () => {
|
||||
const longcatPresets = [
|
||||
findLongcatPreset(providerPresets),
|
||||
findLongcatPreset(claudeDesktopProviderPresets),
|
||||
findLongcatPreset(hermesProviderPresets),
|
||||
findLongcatPreset(opencodeProviderPresets),
|
||||
findLongcatPreset(openclawProviderPresets),
|
||||
findLongcatPreset(codexProviderPresets),
|
||||
];
|
||||
const serializedPresets = JSON.stringify(longcatPresets);
|
||||
|
||||
for (const removedName of REMOVED_LONGCAT_NAMES) {
|
||||
expect(serializedPresets).not.toContain(removedName);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
);
|
||||
expect(preset?.settingsConfig.options?.setCacheKey).toBe(true);
|
||||
expect(models).toHaveProperty("openai/gpt-5.3-codex");
|
||||
expect(models).toHaveProperty("anthropic/claude-sonnet-4.6");
|
||||
expect(models).toHaveProperty("anthropic/claude-sonnet-5");
|
||||
expect(models).toHaveProperty("google/gemini-3.5-flash");
|
||||
expect(models["google/gemini-3.5-flash"]?.name).toBe("Gemini 3.5 Flash");
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
expect(preset?.settingsConfig.api).toBe("openai-completions");
|
||||
expect(modelIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai/gpt-5.3-codex",
|
||||
"openai/gpt-5.2",
|
||||
"google/gemini-3.5-flash",
|
||||
@@ -55,7 +55,7 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
cost: { input: 1.5, output: 9, cacheRead: 0.15 },
|
||||
});
|
||||
expect(preset?.suggestedDefaults?.model).toEqual({
|
||||
primary: "therouter/anthropic/claude-sonnet-4.6",
|
||||
primary: "therouter/anthropic/claude-sonnet-5",
|
||||
fallbacks: [
|
||||
"therouter/openai/gpt-5.2",
|
||||
"therouter/google/gemini-3.5-flash",
|
||||
|
||||
@@ -17,12 +17,12 @@ describe("TheRouter provider presets", () => {
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe("https://api.therouter.ai");
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("");
|
||||
expect(env.ANTHROPIC_API_KEY).toBe("");
|
||||
expect(env.ANTHROPIC_MODEL).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(env.ANTHROPIC_MODEL).toBe("anthropic/claude-sonnet-5");
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe(
|
||||
"anthropic/claude-haiku-4.5",
|
||||
);
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-sonnet-5",
|
||||
);
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("anthropic/claude-opus-4.8");
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { Provider } from "@/types";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
update: vi.fn(),
|
||||
ensureClaudeDesktopOfficialProvider: vi.fn(),
|
||||
ensureCodexOfficialProvider: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
updateTrayMenu: vi.fn(),
|
||||
}));
|
||||
@@ -19,8 +21,11 @@ const uuidMocks = vi.hoisted(() => ({
|
||||
vi.mock("@/lib/api", () => ({
|
||||
providersApi: {
|
||||
add: (...args: unknown[]) => apiMocks.add(...args),
|
||||
update: (...args: unknown[]) => apiMocks.update(...args),
|
||||
ensureClaudeDesktopOfficialProvider: (...args: unknown[]) =>
|
||||
apiMocks.ensureClaudeDesktopOfficialProvider(...args),
|
||||
ensureCodexOfficialProvider: (...args: unknown[]) =>
|
||||
apiMocks.ensureCodexOfficialProvider(...args),
|
||||
getAll: (...args: unknown[]) => apiMocks.getAll(...args),
|
||||
updateTrayMenu: (...args: unknown[]) => apiMocks.updateTrayMenu(...args),
|
||||
},
|
||||
@@ -56,9 +61,11 @@ function createWrapper() {
|
||||
|
||||
beforeEach(() => {
|
||||
apiMocks.add.mockReset().mockResolvedValue(true);
|
||||
apiMocks.update.mockReset().mockResolvedValue(true);
|
||||
apiMocks.ensureClaudeDesktopOfficialProvider
|
||||
.mockReset()
|
||||
.mockResolvedValue(true);
|
||||
apiMocks.ensureCodexOfficialProvider.mockReset().mockResolvedValue(true);
|
||||
apiMocks.getAll.mockReset().mockResolvedValue({});
|
||||
apiMocks.updateTrayMenu.mockReset().mockResolvedValue(true);
|
||||
uuidMocks.generateUUID.mockReset().mockReturnValue("generated-uuid");
|
||||
@@ -133,4 +140,106 @@ describe("useAddProviderMutation", () => {
|
||||
expect(apiMocks.add).not.toHaveBeenCalled();
|
||||
expect(persistedProvider).toEqual(seedProvider);
|
||||
});
|
||||
|
||||
it("persists a managed account binding onto the fixed Codex official seed", async () => {
|
||||
const seedProvider: Provider = {
|
||||
id: "codex-official",
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
};
|
||||
apiMocks.getAll.mockResolvedValueOnce({
|
||||
"codex-official": seedProvider,
|
||||
});
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(() => useAddProviderMutation("codex"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const persistedProvider = await act(async () =>
|
||||
result.current.mutateAsync({
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
meta: {
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
},
|
||||
ensureCodexOfficialSeed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(apiMocks.ensureCodexOfficialProvider).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.getAll).toHaveBeenCalledWith("codex");
|
||||
expect(apiMocks.add).not.toHaveBeenCalled();
|
||||
expect(apiMocks.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "codex-official",
|
||||
meta: {
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"codex",
|
||||
);
|
||||
expect(persistedProvider).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "codex-official",
|
||||
meta: expect.objectContaining({
|
||||
authBinding: expect.objectContaining({
|
||||
accountId: "acct-managed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a prior managed binding when Codex Official uses native login", async () => {
|
||||
const seedProvider: Provider = {
|
||||
id: "codex-official",
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
meta: {
|
||||
providerType: "codex_oauth",
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
},
|
||||
};
|
||||
apiMocks.getAll.mockResolvedValueOnce({
|
||||
"codex-official": seedProvider,
|
||||
});
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(() => useAddProviderMutation("codex"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const persistedProvider = await act(async () =>
|
||||
result.current.mutateAsync({
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
meta: {},
|
||||
ensureCodexOfficialSeed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(apiMocks.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "codex-official",
|
||||
meta: {},
|
||||
}),
|
||||
"codex",
|
||||
);
|
||||
expect(persistedProvider.meta).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useApiKeyState } from "@/components/providers/forms/hooks/useApiKeyState";
|
||||
|
||||
describe("useApiKeyState", () => {
|
||||
it("shows and creates Claude API key for uncategorized edit providers", () => {
|
||||
const onConfigChange = vi.fn();
|
||||
const initialConfig = JSON.stringify({ env: {} }, null, 2);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiKeyState({
|
||||
initialConfig,
|
||||
onConfigChange,
|
||||
selectedPresetId: null,
|
||||
category: undefined,
|
||||
appType: "claude",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.showApiKey(initialConfig, true)).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.handleApiKeyChange("sk-test");
|
||||
});
|
||||
|
||||
const updated = JSON.parse(onConfigChange.mock.calls.at(-1)?.[0]);
|
||||
expect(updated.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-test");
|
||||
});
|
||||
|
||||
it("keeps official and cloud provider edit behavior conservative", () => {
|
||||
const initialConfig = JSON.stringify({ env: {} }, null, 2);
|
||||
const officialConfigChange = vi.fn();
|
||||
|
||||
const official = renderHook(() =>
|
||||
useApiKeyState({
|
||||
initialConfig,
|
||||
onConfigChange: officialConfigChange,
|
||||
selectedPresetId: null,
|
||||
category: "official",
|
||||
appType: "claude",
|
||||
}),
|
||||
);
|
||||
expect(official.result.current.showApiKey(initialConfig, true)).toBe(false);
|
||||
act(() => {
|
||||
official.result.current.handleApiKeyChange("sk-official");
|
||||
});
|
||||
expect(officialConfigChange).toHaveBeenLastCalledWith(initialConfig);
|
||||
|
||||
const cloudProviderConfigChange = vi.fn();
|
||||
const cloudProvider = renderHook(() =>
|
||||
useApiKeyState({
|
||||
initialConfig,
|
||||
onConfigChange: cloudProviderConfigChange,
|
||||
selectedPresetId: null,
|
||||
category: "cloud_provider",
|
||||
appType: "claude",
|
||||
}),
|
||||
);
|
||||
expect(cloudProvider.result.current.showApiKey(initialConfig, true)).toBe(
|
||||
false,
|
||||
);
|
||||
act(() => {
|
||||
cloudProvider.result.current.handleApiKeyChange("sk-cloud");
|
||||
});
|
||||
expect(cloudProviderConfigChange).toHaveBeenLastCalledWith(initialConfig);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { useGeminiCommonConfig } from "@/components/providers/forms/hooks/useGem
|
||||
const getCommonConfigSnippetMock = vi.fn();
|
||||
const setCommonConfigSnippetMock = vi.fn();
|
||||
const extractCommonConfigSnippetMock = vi.fn();
|
||||
const updateTomlCommonConfigSnippetMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
configApi: {
|
||||
@@ -15,6 +16,8 @@ vi.mock("@/lib/api", () => ({
|
||||
setCommonConfigSnippetMock(...args),
|
||||
extractCommonConfigSnippet: (...args: unknown[]) =>
|
||||
extractCommonConfigSnippetMock(...args),
|
||||
updateTomlCommonConfigSnippet: (...args: unknown[]) =>
|
||||
updateTomlCommonConfigSnippetMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,6 +26,9 @@ describe("common config snippet saving", () => {
|
||||
getCommonConfigSnippetMock.mockResolvedValue("");
|
||||
setCommonConfigSnippetMock.mockResolvedValue(undefined);
|
||||
extractCommonConfigSnippetMock.mockResolvedValue("");
|
||||
updateTomlCommonConfigSnippetMock.mockImplementation(
|
||||
async (configToml: string) => configToml,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not persist an invalid Codex common config snippet", async () => {
|
||||
@@ -36,9 +42,9 @@ describe("common config snippet saving", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
let saved = false;
|
||||
act(() => {
|
||||
saved = result.current.handleCommonConfigSnippetChange(
|
||||
let saved = true;
|
||||
await act(async () => {
|
||||
saved = await result.current.handleCommonConfigSnippetChange(
|
||||
"base_url = https://bad.example/v1",
|
||||
);
|
||||
});
|
||||
@@ -49,6 +55,99 @@ describe("common config snippet saving", () => {
|
||||
expect(result.current.commonConfigError).toContain("invalid value");
|
||||
});
|
||||
|
||||
it("discards stale toggle results when a newer toggle finishes first", async () => {
|
||||
getCommonConfigSnippetMock.mockResolvedValue(
|
||||
"[tui]\nnotifications = true\n",
|
||||
);
|
||||
|
||||
const onConfigChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useCodexCommonConfig({
|
||||
codexConfig: 'model = "gpt-5"',
|
||||
onConfigChange,
|
||||
initialData: { settingsConfig: { config: 'model = "gpt-5"' } },
|
||||
initialEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
|
||||
|
||||
// 第一次调用(勾选 on 的 merge)挂起,第二次(取消勾选的剥离)立即返回:
|
||||
// 模拟后端乱序完成
|
||||
let resolveMerge: ((value: string) => void) | undefined;
|
||||
updateTomlCommonConfigSnippetMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveMerge = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(async (configToml: string) => configToml);
|
||||
|
||||
await act(async () => {
|
||||
const mergePending = result.current.handleCommonConfigToggle(true);
|
||||
const removeDone = result.current.handleCommonConfigToggle(false);
|
||||
await removeDone;
|
||||
// on 的合并结果此时才姗姗来迟——必须被序号守卫丢弃
|
||||
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
|
||||
await mergePending;
|
||||
});
|
||||
|
||||
// 用户最后一次操作是 off:过期的 on 结果不得翻转开关或改写配置
|
||||
expect(result.current.useCommonConfig).toBe(false);
|
||||
const lastConfig = onConfigChange.mock.calls.at(-1)?.[0] as string;
|
||||
expect(lastConfig).not.toContain("[tui]");
|
||||
});
|
||||
|
||||
it("discards async merge results when the user edited the config while in flight", async () => {
|
||||
getCommonConfigSnippetMock.mockResolvedValue(
|
||||
"[tui]\nnotifications = true\n",
|
||||
);
|
||||
|
||||
const initialData = { settingsConfig: { config: 'model = "gpt-5"' } };
|
||||
const onConfigChange = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ config }: { config: string }) =>
|
||||
useCodexCommonConfig({
|
||||
codexConfig: config,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
initialEnabled: false,
|
||||
}),
|
||||
{ initialProps: { config: 'model = "gpt-5"' } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
|
||||
|
||||
let resolveMerge: ((value: string) => void) | undefined;
|
||||
updateTomlCommonConfigSnippetMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveMerge = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
let togglePending: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
togglePending = result.current.handleCommonConfigToggle(true);
|
||||
});
|
||||
|
||||
// merge 在飞期间,用户在编辑器里手动改了 config(不经过 hook,
|
||||
// 序号不变,只有 codexConfig prop 变化)
|
||||
rerender({ config: 'model = "gpt-6-user-edit"' });
|
||||
|
||||
await act(async () => {
|
||||
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
|
||||
await togglePending;
|
||||
});
|
||||
|
||||
// 基于陈旧基线的合并结果必须被丢弃,不得覆盖用户的手动编辑
|
||||
expect(onConfigChange).not.toHaveBeenCalled();
|
||||
expect(result.current.useCommonConfig).toBe(false);
|
||||
});
|
||||
|
||||
it("does not persist an invalid Gemini common config snippet", async () => {
|
||||
const onEnvChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -48,6 +48,7 @@ const createSettings = (
|
||||
enableClaudePluginIntegration: false,
|
||||
claudeConfigDir: "/claude/custom",
|
||||
codexConfigDir: "/codex/custom",
|
||||
grokConfigDir: "/grok/custom",
|
||||
language: "zh",
|
||||
...overrides,
|
||||
});
|
||||
@@ -68,6 +69,7 @@ describe("useDirectorySettings", () => {
|
||||
if (app === "claude") return "/remote/claude";
|
||||
if (app === "codex") return "/remote/codex";
|
||||
if (app === "gemini") return "/remote/gemini";
|
||||
if (app === "grokbuild") return "/remote/grok";
|
||||
if (app === "opencode") return "/remote/opencode";
|
||||
if (app === "openclaw") return "/remote/openclaw";
|
||||
return "/remote/hermes";
|
||||
@@ -90,6 +92,7 @@ describe("useDirectorySettings", () => {
|
||||
claude: "/remote/claude",
|
||||
codex: "/remote/codex",
|
||||
gemini: "/remote/gemini",
|
||||
grokbuild: "/remote/grok",
|
||||
opencode: "/remote/opencode",
|
||||
openclaw: "/remote/openclaw",
|
||||
hermes: "/remote/hermes",
|
||||
@@ -249,6 +252,7 @@ describe("useDirectorySettings", () => {
|
||||
claude: "/server/claude",
|
||||
codex: "/server/codex",
|
||||
gemini: "/server/gemini",
|
||||
grokbuild: "/server/grok",
|
||||
opencode: "/server/opencode",
|
||||
openclaw: "/server/openclaw",
|
||||
});
|
||||
@@ -257,6 +261,7 @@ describe("useDirectorySettings", () => {
|
||||
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
|
||||
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
|
||||
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
|
||||
expect(result.current.resolvedDirs.grokbuild).toBe("/server/grok");
|
||||
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
|
||||
expect(result.current.resolvedDirs.openclaw).toBe("/server/openclaw");
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("useModelState", () => {
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: "DeepSeek V4 Pro",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "Kimi K2",
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: "subagent-model[1M]",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,6 +35,7 @@ describe("useModelState", () => {
|
||||
expect(result.current.defaultOpusModelName).toBe("Kimi K2");
|
||||
expect(result.current.defaultHaikuModel).toBe("legacy-small");
|
||||
expect(result.current.defaultHaikuModelName).toBe("legacy-small");
|
||||
expect(result.current.subagentModel).toBe("subagent-model[1M]");
|
||||
});
|
||||
|
||||
it("writes and clears role display-name env fields without changing model mapping", () => {
|
||||
@@ -94,6 +96,42 @@ describe("useModelState", () => {
|
||||
expect(result.current.defaultSonnetModelName).toBe("deepseek-v4-pro");
|
||||
});
|
||||
|
||||
it("writes and clears the Claude Code subagent model env field", () => {
|
||||
let latestConfig = JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_MODEL: "fallback-model",
|
||||
},
|
||||
});
|
||||
const onConfigChange = vi.fn((config: string) => {
|
||||
latestConfig = config;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelState({
|
||||
settingsConfig: latestConfig,
|
||||
onConfigChange,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange(
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
"subagent-model[1M]",
|
||||
);
|
||||
});
|
||||
|
||||
let env = JSON.parse(latestConfig).env;
|
||||
expect(env.ANTHROPIC_MODEL).toBe("fallback-model");
|
||||
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("subagent-model[1M]");
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange("CLAUDE_CODE_SUBAGENT_MODEL", "");
|
||||
});
|
||||
|
||||
env = JSON.parse(latestConfig).env;
|
||||
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes Claude Code 1M markers for UI toggles", () => {
|
||||
expect(hasClaudeOneMMarker("deepseek-v4-pro[1m]")).toBe(true);
|
||||
expect(hasClaudeOneMMarker("deepseek-v4-pro [1M] ")).toBe(true);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useOpencodeFormState } from "@/components/providers/forms/hooks/useOpencodeFormState";
|
||||
|
||||
const renderOpencodeFormState = (
|
||||
initialSettingsConfig: Record<string, unknown>,
|
||||
) => {
|
||||
let settingsConfig = JSON.stringify(initialSettingsConfig);
|
||||
const onSettingsConfigChange = vi.fn((nextConfig: string) => {
|
||||
settingsConfig = nextConfig;
|
||||
});
|
||||
|
||||
const hook = renderHook(() =>
|
||||
useOpencodeFormState({
|
||||
appId: "opencode",
|
||||
initialData: { settingsConfig: initialSettingsConfig },
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig: () => settingsConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...hook,
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig: () => settingsConfig,
|
||||
};
|
||||
};
|
||||
|
||||
describe("useOpencodeFormState", () => {
|
||||
it("hydrates provider headers from options", () => {
|
||||
const { result } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
expect(result.current.opencodeHeaders).toEqual({
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes provider headers to options", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes options.headers when all provider headers are removed", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves legitimate headers whose names start with header-", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"header-version": "v1",
|
||||
"X-Title": "Old",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({
|
||||
"header-version": "v1",
|
||||
"X-Title": "New",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
|
||||
"header-version": "v1",
|
||||
"X-Title": "New",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legitimate options whose names start with option-", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
"option-mode": "legacy",
|
||||
timeout: 100,
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeExtraOptionsChange({
|
||||
"option-mode": "legacy",
|
||||
timeout: "200",
|
||||
"draft-option:123": "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options).toEqual({
|
||||
"option-mode": "legacy",
|
||||
timeout: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -242,6 +242,124 @@ describe("useProviderActions", () => {
|
||||
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("warns for Grok providers that require the Responses router", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValue(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const providers = [
|
||||
createProvider({
|
||||
id: "grok-chat",
|
||||
category: "custom",
|
||||
meta: { apiFormat: "openai_chat" },
|
||||
}),
|
||||
createProvider({
|
||||
id: "grok-anthropic",
|
||||
category: "custom",
|
||||
meta: { apiFormat: "anthropic" },
|
||||
}),
|
||||
createProvider({
|
||||
id: "grok-full-url",
|
||||
category: "custom",
|
||||
meta: { isFullUrl: true },
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("grokbuild", false),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
for (const provider of providers) {
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
}
|
||||
|
||||
expect(toastWarningMock).toHaveBeenCalledTimes(3);
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("allows the built-in Codex official provider during takeover", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
id: "codex-official",
|
||||
category: "official",
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("codex", true, true),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith("codex-official");
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues blocking other official providers during takeover", async () => {
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
id: "claude-official",
|
||||
category: "official",
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("claude", true, true),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not grant routing capability to a UUID Codex official copy", async () => {
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
id: "generated-uuid",
|
||||
category: "official",
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("codex", true, true),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should sync plugin config when switching Claude provider with integration enabled", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
settingsApiGetMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("useProxyStatus", () => {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
});
|
||||
@@ -115,4 +116,4 @@ describe("useProxyStatus", () => {
|
||||
{ closeButton: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("isTransientUsageError", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("HTTP 5xx → 瞬时(true);4xx → 非瞬时(false)", () => {
|
||||
it("HTTP 5xx / 429(限流)→ 瞬时(true);其余 4xx → 非瞬时(false)", () => {
|
||||
expect(isTransientUsageError(fail("API error (HTTP 500): oops"))).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -60,12 +60,19 @@ describe("isTransientUsageError", () => {
|
||||
expect(
|
||||
isTransientUsageError(fail("API error (HTTP 502): bad gateway")),
|
||||
).toBe(true);
|
||||
// 429 是限流:稍后重试即可恢复,归瞬时——且不应清空 keep-last-good 快照
|
||||
expect(
|
||||
isTransientUsageError(fail("API error (HTTP 429): rate limited")),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientUsageError(fail("HTTP 429 Too Many Requests : x")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientUsageError(fail("Authentication failed (HTTP 403)")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTransientUsageError(fail("Authentication failed (HTTP 401)")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("成功 / 无错误信息 → false", () => {
|
||||
@@ -173,4 +180,111 @@ describe("resolveDisplayUsage (keep-last-good)", () => {
|
||||
const r = resolveDisplayUsage(success, 0, null, now);
|
||||
expect(r.lastGood).toEqual({ data: success, at: now });
|
||||
});
|
||||
|
||||
it("429 限流:窗口内继续展示上次成功,且不清空 lastGood", () => {
|
||||
const prev: LastGoodUsage = { data: ok(42), at: T0 };
|
||||
const now = T0 + 1000;
|
||||
const r = resolveDisplayUsage(
|
||||
fail("API error (HTTP 429): rate limited"),
|
||||
now,
|
||||
prev,
|
||||
now,
|
||||
);
|
||||
expect(r.data).toBe(prev.data); // 掩盖:继续展示上次成功
|
||||
expect(r.lastGood).toBe(prev); // 快照保留,抖动过去后可继续兜底
|
||||
});
|
||||
});
|
||||
|
||||
// 订阅系 hooks(useSubscriptionQuota / useCodexOauthQuota)复用同一决策函数,
|
||||
// 这里用 SubscriptionQuota 的形状锁定泛型行为。
|
||||
describe("resolveDisplayUsage(SubscriptionQuota 形状)", () => {
|
||||
interface QuotaLike {
|
||||
success: boolean;
|
||||
error?: string | null;
|
||||
credentialStatus: string;
|
||||
queriedAt: number | null;
|
||||
}
|
||||
const quotaOk = (queriedAt: number): QuotaLike => ({
|
||||
success: true,
|
||||
error: null,
|
||||
credentialStatus: "valid",
|
||||
queriedAt,
|
||||
});
|
||||
const quotaFail = (error: string): QuotaLike => ({
|
||||
success: false,
|
||||
error,
|
||||
credentialStatus: "valid",
|
||||
queriedAt: null,
|
||||
});
|
||||
|
||||
it("瞬时 5xx:窗口内继续展示上次成功的 quota(含其 queriedAt)", () => {
|
||||
const prev = { data: quotaOk(T0), at: T0 };
|
||||
const now = T0 + 1000;
|
||||
const r = resolveDisplayUsage(
|
||||
quotaFail("API error (HTTP 502): bad gateway"),
|
||||
now,
|
||||
prev,
|
||||
now,
|
||||
);
|
||||
expect(r.data).toBe(prev.data);
|
||||
expect(r.data?.queriedAt).toBe(T0); // 展示的相对时间指向旧成功
|
||||
});
|
||||
|
||||
it("确定性失败(token 过期):立即透出并清空 lastGood", () => {
|
||||
const prev = { data: quotaOk(T0), at: T0 };
|
||||
const now = T0 + 1000;
|
||||
const failure = quotaFail("OAuth token has expired");
|
||||
const r = resolveDisplayUsage(failure, now, prev, now);
|
||||
expect(r.data).toBe(failure);
|
||||
expect(r.lastGood).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// invoke reject(后端 Err:网络/超时/读体中断)时 react-query 保留上次成功 data,
|
||||
// `rejected` 标志让这份"陈旧成功"走同一 keep-last-good 窗口(锚定上次成功时刻
|
||||
// dataUpdatedAt),而不是无限期被当作新鲜成功展示——否则「彻底断网」反而比
|
||||
// 「单次 5xx」掩盖更久。
|
||||
describe("resolveDisplayUsage(rejected:reject 保留的旧成功按窗口过期)", () => {
|
||||
it("窗口内:继续展示旧成功;prevLastGood=null(组件重挂丢 ref)也从 dataUpdatedAt 补种", () => {
|
||||
const stale = ok(42); // react-query 保留的旧成功,dataUpdatedAt = 其成功时刻
|
||||
const now = T0 + KEEP_LAST_GOOD_MS - 1;
|
||||
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
|
||||
expect(r.data).toBe(stale);
|
||||
expect(r.lastQueriedAt).toBe(T0); // 相对时间指向上次真实成功
|
||||
expect(r.lastGood).toEqual({ data: stale, at: T0 });
|
||||
});
|
||||
|
||||
it("超窗(>= keepMs):不再展示旧成功(data 置空由调用方合成失败占位),快照保留不清空", () => {
|
||||
const stale = ok(42);
|
||||
const now = T0 + KEEP_LAST_GOOD_MS;
|
||||
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
|
||||
expect(r.data).toBeUndefined();
|
||||
expect(r.lastQueriedAt).toBe(T0);
|
||||
expect(r.lastGood).toEqual({ data: stale, at: T0 }); // reject 属瞬时,不清快照
|
||||
});
|
||||
|
||||
it("reject 且从无成功(raw=undefined,首次查询即失败):data 为 undefined,lastGood 不变", () => {
|
||||
const r = resolveDisplayUsage(undefined, 0, null, T0, { rejected: true });
|
||||
expect(r.data).toBeUndefined();
|
||||
expect(r.lastQueriedAt).toBeNull();
|
||||
expect(r.lastGood).toBeNull();
|
||||
});
|
||||
|
||||
it("reject 但保留的是确定性失败结果:照旧立即透出并清空 lastGood(rejected 只作用于成功值)", () => {
|
||||
const prev: LastGoodUsage = { data: ok(42), at: T0 };
|
||||
const failure = fail("Authentication failed (HTTP 401)");
|
||||
const r = resolveDisplayUsage(failure, T0 + 1000, prev, T0 + 1000, {
|
||||
rejected: true,
|
||||
});
|
||||
expect(r.data).toBe(failure);
|
||||
expect(r.lastGood).toBeNull();
|
||||
});
|
||||
|
||||
it("rejected 未传(默认 false):成功照常作为新鲜结果,既有语义不变", () => {
|
||||
const success = ok(7);
|
||||
const now = T0 + KEEP_LAST_GOOD_MS * 3; // 距 T0 很久也无妨:非 reject 的成功就是新鲜的
|
||||
const r = resolveDisplayUsage(success, now, null, now);
|
||||
expect(r.data).toBe(success);
|
||||
expect(r.lastGood).toEqual({ data: success, at: now });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,6 +337,7 @@ export const handlers = [
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
}),
|
||||
),
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const createDefaultProviders = (): ProvidersByApp => ({
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
},
|
||||
grokbuild: {},
|
||||
opencode: {},
|
||||
openclaw: {},
|
||||
hermes: {},
|
||||
@@ -79,6 +80,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({
|
||||
"claude-desktop": "",
|
||||
codex: "codex-1",
|
||||
gemini: "gemini-1",
|
||||
grokbuild: "",
|
||||
opencode: "",
|
||||
openclaw: "",
|
||||
hermes: "",
|
||||
@@ -191,6 +193,7 @@ let mcpConfigs: McpConfigState = {
|
||||
},
|
||||
},
|
||||
gemini: {},
|
||||
grokbuild: {},
|
||||
opencode: {},
|
||||
openclaw: {},
|
||||
hermes: {},
|
||||
@@ -259,6 +262,7 @@ export const resetProviderState = () => {
|
||||
},
|
||||
},
|
||||
gemini: {},
|
||||
grokbuild: {},
|
||||
opencode: {},
|
||||
openclaw: {},
|
||||
hermes: {},
|
||||
|
||||
Reference in New Issue
Block a user