fix(providers): preserve custom endpoints in meta during add/edit operations

Fixed two critical data loss bugs where user-added custom endpoints were discarded:

1. **AddProviderDialog**: Form submission ignored values.meta from ProviderForm and
   re-inferred URLs only from presets/config, causing loss of endpoints added via
   speed test modal. Now prioritizes form-collected meta and uses fallback inference
   only when custom_endpoints is missing.

2. **ProviderForm**: Edit mode always returned initialData.meta, discarding any
   changes made in the speed test modal. Now uses mergeProviderMeta to properly
   merge customEndpointsMap with existing meta fields.

Changes:
- Extract mergeProviderMeta utility to handle meta field merging logic
- Preserve other meta fields (e.g., usage_script) during endpoint updates
- Unify new/edit code paths to use consistent meta handling
- Add comprehensive unit tests for meta merging scenarios
- Add integration tests for AddProviderDialog submission flow

Impact:
- Third-party and custom providers can now reliably manage multiple endpoints
- Edit operations correctly reflect user modifications
- No data loss for existing meta fields like usage_script
This commit is contained in:
Jason
2025-10-28 20:28:11 +08:00
parent 1841f8b462
commit 7d56aed543
5 changed files with 318 additions and 75 deletions

View File

@@ -47,84 +47,93 @@ export function AddProviderDialog({
websiteUrl: values.websiteUrl?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig, settingsConfig: parsedConfig,
...(values.presetCategory ? { category: values.presetCategory } : {}), ...(values.presetCategory ? { category: values.presetCategory } : {}),
...(values.meta ? { meta: values.meta } : {}),
}; };
// 收集端点候选(仅新增供应商时) const hasCustomEndpoints =
// 1. 从预设配置中获取 endpointCandidates providerData.meta?.custom_endpoints &&
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url) Object.keys(providerData.meta.custom_endpoints).length > 0;
const urlSet = new Set<string>();
const addUrl = (rawUrl?: string) => { if (!hasCustomEndpoints) {
const url = (rawUrl || "").trim().replace(/\/+$/, ""); // 收集端点候选(仅在缺少自定义端点时兜底)
if (url && url.startsWith("http")) { // 1. 从预设配置中获取 endpointCandidates
urlSet.add(url); // 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
} const urlSet = new Set<string>();
};
// 如果选择了预设,获取预设中的 endpointCandidates const addUrl = (rawUrl?: string) => {
if (values.presetId) { const url = (rawUrl || "").trim().replace(/\/+$/, "");
if (appType === "claude") { if (url && url.startsWith("http")) {
const presets = providerPresets; urlSet.add(url);
const presetIndex = parseInt(values.presetId.replace("claude-", "")); }
if ( };
!isNaN(presetIndex) &&
presetIndex >= 0 && if (values.presetId) {
presetIndex < presets.length if (appType === "claude") {
) { const presets = providerPresets;
const preset = presets[presetIndex]; const presetIndex = parseInt(
if (preset?.endpointCandidates) { values.presetId.replace("claude-", ""),
preset.endpointCandidates.forEach(addUrl); );
if (
!isNaN(presetIndex) &&
presetIndex >= 0 &&
presetIndex < presets.length
) {
const preset = presets[presetIndex];
if (preset?.endpointCandidates) {
preset.endpointCandidates.forEach(addUrl);
}
} }
} else if (appType === "codex") {
const presets = codexProviderPresets;
const presetIndex = parseInt(
values.presetId.replace("codex-", ""),
);
if (
!isNaN(presetIndex) &&
presetIndex >= 0 &&
presetIndex < presets.length
) {
const preset = presets[presetIndex];
if (Array.isArray(preset.endpointCandidates)) {
preset.endpointCandidates.forEach(addUrl);
}
}
}
}
if (appType === "claude") {
const env = parsedConfig.env as Record<string, any> | undefined;
if (env?.ANTHROPIC_BASE_URL) {
addUrl(env.ANTHROPIC_BASE_URL);
} }
} else if (appType === "codex") { } else if (appType === "codex") {
const presets = codexProviderPresets; const config = parsedConfig.config as string | undefined;
const presetIndex = parseInt(values.presetId.replace("codex-", "")); if (config) {
if ( const baseUrlMatch =
!isNaN(presetIndex) && config.match(/base_url\s*=\s*["']([^"']+)["']/);
presetIndex >= 0 && if (baseUrlMatch?.[1]) {
presetIndex < presets.length addUrl(baseUrlMatch[1]);
) {
const preset = presets[presetIndex];
if ((preset as any).endpointCandidates) {
(preset as any).endpointCandidates.forEach(addUrl);
} }
} }
} }
}
// 从当前配置中提取 baseUrl const urls = Array.from(urlSet);
if (appType === "claude") { if (urls.length > 0) {
const env = parsedConfig.env as Record<string, any> | undefined; const now = Date.now();
if (env?.ANTHROPIC_BASE_URL) { const customEndpoints: Record<string, CustomEndpoint> = {};
addUrl(env.ANTHROPIC_BASE_URL); urls.forEach((url) => {
} customEndpoints[url] = {
} else if (appType === "codex") { url,
// Codex 的 baseUrl 在 config.toml 字符串中 addedAt: now,
const config = parsedConfig.config as string | undefined; lastUsed: undefined,
if (config) { };
const baseUrlMatch = config.match(/base_url\s*=\s*["']([^"']+)["']/); });
if (baseUrlMatch?.[1]) {
addUrl(baseUrlMatch[1]);
}
}
}
// 如果收集到了端点,添加到 meta.custom_endpoints providerData.meta = {
const urls = Array.from(urlSet); ...(providerData.meta ?? {}),
if (urls.length > 0) { custom_endpoints: customEndpoints,
const now = Date.now();
const customEndpoints: Record<string, CustomEndpoint> = {};
urls.forEach((url) => {
customEndpoints[url] = {
url,
addedAt: now,
lastUsed: undefined,
}; };
}); }
providerData.meta = {
custom_endpoints: customEndpoints,
};
} }
await onSubmit(providerData); await onSubmit(providerData);

View File

@@ -6,13 +6,14 @@ import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppType } from "@/lib/api"; import type { AppType } from "@/lib/api";
import type { ProviderCategory, CustomEndpoint, ProviderMeta } from "@/types"; import type { ProviderCategory, ProviderMeta } from "@/types";
import { providerPresets, type ProviderPreset } from "@/config/providerPresets"; import { providerPresets, type ProviderPreset } from "@/config/providerPresets";
import { import {
codexProviderPresets, codexProviderPresets,
type CodexProviderPreset, type CodexProviderPreset,
} from "@/config/codexProviderPresets"; } from "@/config/codexProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import CodexConfigEditor from "./CodexConfigEditor"; import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor"; import { CommonConfigEditor } from "./CommonConfigEditor";
import { ProviderPresetSelector } from "./ProviderPresetSelector"; import { ProviderPresetSelector } from "./ProviderPresetSelector";
@@ -324,12 +325,9 @@ export function ProviderForm({
} }
// 处理 meta 字段(新建与编辑使用不同策略) // 处理 meta 字段(新建与编辑使用不同策略)
if (initialData?.meta) { const mergedMeta = mergeProviderMeta(initialData?.meta, customEndpointsMap);
// 编辑模式:后端已通过 API 更新 meta直接使用原有值 if (mergedMeta) {
payload.meta = initialData.meta; payload.meta = mergedMeta;
} else if (customEndpointsMap) {
// 新建模式:从表单收集的自定义端点打包到 meta
payload.meta = { custom_endpoints: customEndpointsMap };
} }
onSubmit(payload); onSubmit(payload);
@@ -580,7 +578,5 @@ export function ProviderForm({
export type ProviderFormValues = ProviderFormData & { export type ProviderFormValues = ProviderFormData & {
presetId?: string; presetId?: string;
presetCategory?: ProviderCategory; presetCategory?: ProviderCategory;
meta?: { meta?: ProviderMeta;
custom_endpoints?: Record<string, CustomEndpoint>;
};
}; };

View File

@@ -0,0 +1,36 @@
import type { CustomEndpoint, ProviderMeta } from "@/types";
/**
* 合并供应商元数据中的自定义端点。
* - 当 customEndpoints 为空对象或 null 时,移除自定义端点但保留其它元数据。
* - 当 customEndpoints 存在时,覆盖原有自定义端点。
* - 若结果为空对象则返回 undefined避免写入空 meta。
*/
export function mergeProviderMeta(
initialMeta: ProviderMeta | undefined,
customEndpoints:
| Record<string, CustomEndpoint>
| null
| undefined,
): ProviderMeta | undefined {
const hasCustomEndpoints =
!!customEndpoints && Object.keys(customEndpoints).length > 0;
if (hasCustomEndpoints) {
return {
...(initialMeta ? { ...initialMeta } : {}),
custom_endpoints: customEndpoints!,
};
}
if (!initialMeta) {
return undefined;
}
if ("custom_endpoints" in initialMeta) {
const { custom_endpoints, ...rest } = initialMeta;
return Object.keys(rest).length > 0 ? rest : undefined;
}
return { ...initialMeta };
}

View File

@@ -0,0 +1,122 @@
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";
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h1>{children}</h1>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
let mockFormValues: ProviderFormValues;
vi.mock("@/components/providers/forms/ProviderForm", () => ({
ProviderForm: ({ onSubmit }: { onSubmit: (values: ProviderFormValues) => void }) => (
<form
id="provider-form"
onSubmit={(event) => {
event.preventDefault();
onSubmit(mockFormValues);
}}
/>
),
}));
describe("AddProviderDialog", () => {
beforeEach(() => {
mockFormValues = {
name: "Test Provider",
websiteUrl: "https://provider.example.com",
settingsConfig: JSON.stringify({ env: {}, config: {} }),
meta: {
custom_endpoints: {
"https://api.new-endpoint.com": {
url: "https://api.new-endpoint.com",
addedAt: 1,
},
},
},
};
});
it("使用 ProviderForm 返回的自定义端点", async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
const handleOpenChange = vi.fn();
render(
<AddProviderDialog
open
onOpenChange={handleOpenChange}
appType="claude"
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.meta?.custom_endpoints).toEqual(
mockFormValues.meta?.custom_endpoints,
);
expect(handleOpenChange).toHaveBeenCalledWith(false);
});
it("在缺少自定义端点时回退到配置中的 baseUrl", async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
mockFormValues = {
name: "Base URL Provider",
websiteUrl: "",
settingsConfig: JSON.stringify({
env: { ANTHROPIC_BASE_URL: "https://claude.base" },
config: {},
}),
};
render(
<AddProviderDialog
open
onOpenChange={vi.fn()}
appType="claude"
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.meta?.custom_endpoints).toEqual({
"https://claude.base": {
url: "https://claude.base",
addedAt: expect.any(Number),
lastUsed: undefined,
},
});
});
});

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { ProviderMeta } from "@/types";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
const buildEndpoint = (url: string) => ({
url,
addedAt: 1,
});
describe("mergeProviderMeta", () => {
it("returns undefined when no initial meta and no endpoints", () => {
expect(mergeProviderMeta(undefined, null)).toBeUndefined();
expect(mergeProviderMeta(undefined, undefined)).toBeUndefined();
});
it("creates meta when endpoints are provided for new provider", () => {
const result = mergeProviderMeta(undefined, {
"https://example.com": buildEndpoint("https://example.com"),
});
expect(result).toEqual({
custom_endpoints: {
"https://example.com": buildEndpoint("https://example.com"),
},
});
});
it("overrides custom endpoints but preserves other fields", () => {
const initial: ProviderMeta = {
usage_script: {
enabled: true,
language: "javascript",
code: "console.log(1);",
},
custom_endpoints: {
"https://old.com": buildEndpoint("https://old.com"),
},
};
const result = mergeProviderMeta(initial, {
"https://new.com": buildEndpoint("https://new.com"),
});
expect(result).toEqual({
usage_script: initial.usage_script,
custom_endpoints: {
"https://new.com": buildEndpoint("https://new.com"),
},
});
});
it("removes custom endpoints when result is empty but keeps other meta", () => {
const initial: ProviderMeta = {
usage_script: {
enabled: true,
language: "javascript",
code: "console.log(1);",
},
custom_endpoints: {
"https://example.com": buildEndpoint("https://example.com"),
},
};
const result = mergeProviderMeta(initial, null);
expect(result).toEqual({
usage_script: initial.usage_script,
});
});
it("returns undefined when removing last field", () => {
const initial: ProviderMeta = {
custom_endpoints: {
"https://example.com": buildEndpoint("https://example.com"),
},
};
expect(mergeProviderMeta(initial, null)).toBeUndefined();
});
});