mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
* fix(model-fetch): honor provider header overrides when fetching models The backend command `fetch_models_for_config` already accepts `request_headers` and the Pi form passes them, but the Claude and Codex forms never forwarded the provider's local-proxy Header override JSON, so gateways that require extra headers (e.g. Cloudflare AI Gateway's `cf-aig-authorization`) rejected the GET /v1/models probe (fixes #7747). Parse the Header override JSON with the shared `parseHeaderOverrideJson` rules via a new `modelFetchRequestHeaders` helper and send the validated headers along with the model fetch in both forms; invalid or empty overrides silently fall back to a plain fetch while the form field keeps surfacing the validation error. Fixes #7747 Generated-by: GLM-5.3-Flash (ZCode) * fix(codex): invalidate fetched models when header overrides change The header override field feeds model fetching (requestHeaders), but only handleFetchModels picked it up as a callback dependency. The invalidation effect that bumps fetchModelsSeqRef and clears fetchedModels did not list it, so after a fetch completed (or while one was in flight) editing only the header override kept the previous identity's model list on screen; users could select models the current identity cannot access into the default model or the model mapping. The Grok Build form reuses this component and inherits the fix. Add localProxyHeadersOverride to the invalidation effect's dependencies alongside Base URL / API Key / custom UA, and cover both regression scenarios with component tests: editing the override after a completed fetch clears the list, and an in-flight response is dropped by the seq guard when the override changes while the request is pending. ClaudeFormFields needs no matching change: it has no model-list invalidation effect at all (its fetchedModels also survive Base URL / API Key edits), which is pre-existing behavior outside this PR's scope. Generated-by: GLM-5.3-Flash (ZCode) * test(codex): cover modelFetchRequestHeaders in the modelFetch test mock CodexFormFields.modelFetch.test.tsx replaces the whole model-fetch module with an enumerated factory mock, so the new modelFetchRequestHeaders export crashed the four scenarios that trigger the fetch-models path ("No modelFetchRequestHeaders export is defined on the mock"), and the config route assertion needed the new trailing options argument. Add the missing export to the mock factory and pin the extra { requestHeaders } argument; no production code touched. Generated-by: GLM-5.3-Flash (ZCode)
179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import type { ComponentProps, PropsWithChildren } from "react";
|
|
import { useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { CodexFormFields } from "@/components/providers/forms/CodexFormFields";
|
|
import { Form } from "@/components/ui/form";
|
|
import type { FetchedModel } from "@/lib/api/model-fetch";
|
|
|
|
const modelFetchApiMock = vi.hoisted(() => ({
|
|
fetchModelsForConfig: vi.fn(),
|
|
fetchXaiOauthModels: vi.fn(),
|
|
modelFetchRequestHeaders: vi.fn(),
|
|
showFetchModelsError: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/api/model-fetch", () => ({
|
|
fetchModelsForConfig: modelFetchApiMock.fetchModelsForConfig,
|
|
fetchXaiOauthModels: modelFetchApiMock.fetchXaiOauthModels,
|
|
modelFetchRequestHeaders: modelFetchApiMock.modelFetchRequestHeaders,
|
|
showFetchModelsError: modelFetchApiMock.showFetchModelsError,
|
|
}));
|
|
|
|
// 上游 v4.0.0 起 CodexFormFields 内部使用 useModelMetadataFill(依赖
|
|
// QueryClientProvider);本文件只测 model fetch 行为,mock 成 no-op 即可。
|
|
vi.mock("@/hooks/useModelMetadataFill", () => ({
|
|
useModelMetadataFill: () => () => {},
|
|
}));
|
|
|
|
type CodexFormFieldsProps = ComponentProps<typeof CodexFormFields>;
|
|
|
|
const FormShell = ({ children }: PropsWithChildren) => {
|
|
const form = useForm();
|
|
|
|
return <Form {...form}>{children}</Form>;
|
|
};
|
|
|
|
// RTL's default whitespace normalizer collapses the multi-line placeholder,
|
|
// so query by a distinctive fragment instead of the exact string.
|
|
const HEADERS_PLACEHOLDER = /X-Provider/;
|
|
|
|
// The harness owns the headers override state so editing the textarea
|
|
// reproduces the exact user flow: an existing header override is changed
|
|
// while Base URL / API Key stay untouched.
|
|
const INITIAL_HEADERS_OVERRIDE = '{"X-Tenant": "tenant-1"}';
|
|
|
|
function renderCodexForm() {
|
|
const baseProps: CodexFormFieldsProps = {
|
|
codexApiKey: "sk-test",
|
|
onApiKeyChange: vi.fn(),
|
|
category: "third_party",
|
|
shouldShowApiKeyLink: false,
|
|
websiteUrl: "",
|
|
codexBaseUrl: "https://api.example.com",
|
|
onBaseUrlChange: vi.fn(),
|
|
isFullUrl: false,
|
|
onFullUrlChange: vi.fn(),
|
|
isEndpointModalOpen: false,
|
|
onEndpointModalToggle: vi.fn(),
|
|
autoSelect: false,
|
|
onAutoSelectChange: vi.fn(),
|
|
codexModel: "",
|
|
onModelChange: vi.fn(),
|
|
apiFormat: "openai_responses",
|
|
onApiFormatChange: vi.fn(),
|
|
anthropicAuthField: "ANTHROPIC_AUTH_TOKEN",
|
|
onAnthropicAuthFieldChange: vi.fn(),
|
|
impersonateClaudeCode: false,
|
|
onImpersonateClaudeCodeChange: vi.fn(),
|
|
maxOutputTokens: "",
|
|
onMaxOutputTokensChange: vi.fn(),
|
|
promptCacheRouting: "auto",
|
|
onPromptCacheRoutingChange: vi.fn(),
|
|
speedTestEndpoints: [],
|
|
customUserAgent: "",
|
|
onCustomUserAgentChange: vi.fn(),
|
|
localProxyHeadersOverride: "",
|
|
onLocalProxyHeadersOverrideChange: vi.fn(),
|
|
localProxyBodyOverride: "",
|
|
onLocalProxyBodyOverrideChange: vi.fn(),
|
|
shouldShowSpeedTest: true,
|
|
};
|
|
|
|
function Harness() {
|
|
const [headersOverride, setHeadersOverride] = useState(
|
|
INITIAL_HEADERS_OVERRIDE,
|
|
);
|
|
|
|
return (
|
|
<FormShell>
|
|
<CodexFormFields
|
|
{...baseProps}
|
|
localProxyHeadersOverride={headersOverride}
|
|
onLocalProxyHeadersOverrideChange={setHeadersOverride}
|
|
/>
|
|
</FormShell>
|
|
);
|
|
}
|
|
|
|
return render(<Harness />);
|
|
}
|
|
|
|
async function fetchAndSettleModels(models: FetchedModel[]) {
|
|
modelFetchApiMock.fetchModelsForConfig.mockResolvedValueOnce(models);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "providerForm.fetchModels" }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(modelFetchApiMock.fetchModelsForConfig).toHaveBeenCalled();
|
|
});
|
|
|
|
return screen.findByRole("button", { name: "Select model" });
|
|
}
|
|
|
|
function changeHeadersOverride(value: string) {
|
|
fireEvent.change(screen.getByPlaceholderText(HEADERS_PLACEHOLDER), {
|
|
target: { value },
|
|
});
|
|
}
|
|
|
|
describe("CodexFormFields model fetch identity", () => {
|
|
beforeEach(() => {
|
|
modelFetchApiMock.fetchModelsForConfig.mockReset();
|
|
modelFetchApiMock.showFetchModelsError.mockReset();
|
|
});
|
|
|
|
it("修改 Header 覆盖后清空已取到的模型列表", async () => {
|
|
renderCodexForm();
|
|
|
|
// Header 覆盖已有初值,高级区自动展开,textarea 可直接编辑
|
|
await fetchAndSettleModels([{ id: "model-a", ownedBy: "vendor" }]);
|
|
expect(
|
|
screen.getByRole("button", { name: "Select model" }),
|
|
).toBeInTheDocument();
|
|
|
|
changeHeadersOverride('{"X-Tenant": "tenant-2"}');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("button", { name: "Select model" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("Header 覆盖在途变更后丢弃旧请求返回的模型", async () => {
|
|
renderCodexForm();
|
|
|
|
let resolveFetch!: (models: FetchedModel[]) => void;
|
|
modelFetchApiMock.fetchModelsForConfig.mockImplementationOnce(
|
|
() =>
|
|
new Promise<FetchedModel[]>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "providerForm.fetchModels" }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(modelFetchApiMock.fetchModelsForConfig).toHaveBeenCalled();
|
|
});
|
|
|
|
changeHeadersOverride('{"X-Tenant": "tenant-2"}');
|
|
|
|
// 旧身份的响应此时才落地。先正向等待响应链走完(按钮恢复可用),
|
|
// 再断言模型未上榜——否则否定断言会在响应落地前抢先通过。
|
|
resolveFetch([{ id: "stale-model", ownedBy: "vendor" }]);
|
|
|
|
const fetchButton = screen.getByRole("button", {
|
|
name: "providerForm.fetchModels",
|
|
});
|
|
await waitFor(() => {
|
|
expect(fetchButton).toBeEnabled();
|
|
});
|
|
|
|
expect(screen.queryByRole("button", { name: "Select model" })).toBeNull();
|
|
});
|
|
});
|