feat(ui): edit extraBody and extraHeaders from Advanced settings

The provider dialog had no field for either, so both were config-file
only: invisible in the UI and only editable by hand-writing the config
or writing to the config database directly.

Adds two JSON boxes to the Advanced settings section, next to the usage
connector body that already uses this pattern. They open pre-filled with
whatever the provider carries, and a malformed or non-object value blocks
the save with an inline error instead of writing something the gateway
would ignore.

extraBody and extraHeaders leave providerManualFieldsForSave now that the
draft round-trips them; billing, provider and transformer stay there.
This commit is contained in:
songkuan-zheng
2026-08-17 05:52:05 +00:00
committed by songkuan-zheng
parent a5a5481307
commit e4ad5db099
7 changed files with 215 additions and 8 deletions
+14 -1
View File
@@ -20,7 +20,7 @@ import {
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProxyConfig,
normalizeProfileItem, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
OverviewWidgetConfig, parseProviderAccountDraft, pluginConfigPatchFromSettingsDraft,
OverviewWidgetConfig, parseProviderAccountDraft, parseProviderExtraJsonDraft, pluginConfigPatchFromSettingsDraft,
providerCredentialsFromDraft,
persistLanguagePreference, PluginInstallCandidate, PluginMarketplaceEntry, PluginRoutingConfigTarget, PluginSettingsDraft, presetCapabilitiesFromDraft,
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileAgentOptionsForRuntime, profileDraftWithDetectedAppPath, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
@@ -1700,6 +1700,17 @@ function App() {
return false;
}
const extraBody = parseProviderExtraJsonDraft(providerDraft.extraBodyText, "extraBody");
if (typeof extraBody === "string") {
setProviderProbeError(translateAppErrorMessage(copy, extraBody));
return false;
}
const extraHeaders = parseProviderExtraJsonDraft(providerDraft.extraHeadersText, "extraHeaders");
if (typeof extraHeaders === "string") {
setProviderProbeError(translateAppErrorMessage(copy, extraHeaders));
return false;
}
const providerId = existingProvider?.id ?? providerNameSlug(providerName);
const autoFetchKnownModels = providerAutoFetchKnownModelsForSave({
currentModels: models,
@@ -1716,6 +1727,8 @@ function App() {
autoFetchKnownModels,
capabilities: capabilities.length > 0 ? capabilities : undefined,
account: accountConfig,
extraBody,
extraHeaders,
credentials: credentials.length > 0 ? credentials : undefined,
enabled: existingProvider?.enabled === false ? false : undefined,
icon: providerDraft.icon.trim() || undefined,
@@ -2454,6 +2454,28 @@ export function AddProviderForm({
onChange={onChange}
probe={probe}
/>
<Field className="sm:col-span-2" label={t("Extra request body")} requirement="optional" requirementLabel={t("Optional")}>
<Textarea
className="min-h-[92px] font-mono text-[11px]"
onChange={(event) => onChange({ extraBodyText: event.target.value })}
placeholder={`{\n "default": { "reasoning_effort": "high" }\n}`}
value={draft.extraBodyText}
/>
<div className="mt-1 text-[11px] leading-4 text-muted-foreground">
{t("Merged into every upstream request for this provider. Use \"default\" for all models, or \"byModel\" to target one.")}
</div>
</Field>
<Field className="sm:col-span-2" label={t("Extra request headers")} requirement="optional" requirementLabel={t("Optional")}>
<Textarea
className="min-h-[68px] font-mono text-[11px]"
onChange={(event) => onChange({ extraHeadersText: event.target.value })}
placeholder={`{\n "x-tenant": "acme"\n}`}
value={draft.extraHeadersText}
/>
<div className="mt-1 text-[11px] leading-4 text-muted-foreground">
{t("Sent with every upstream request for this provider, alongside the API key header.")}
</div>
</Field>
<Field className="sm:col-span-2" label={t("Protocol details")}>
<div className="max-h-[128px] overflow-auto rounded-md border border-border bg-background p-2">
{manualProtocolDetection ? (
@@ -279,6 +279,14 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Enter an OpenRouter API key to load providers.": "Enter an OpenRouter API key to load providers.",
"Select a model to load its OpenRouter providers.": "Select a model to load its OpenRouter providers.",
"Excluded OpenRouter providers will be skipped by discount routing and OpenRouter fallbacks.": "Excluded OpenRouter providers will be skipped by discount routing and OpenRouter fallbacks.",
"Extra request body": "Extra request body",
"Merged into every upstream request for this provider. Use \"default\" for all models, or \"byModel\" to target one.": "Merged into every upstream request for this provider. Use \"default\" for all models, or \"byModel\" to target one.",
"Extra request headers": "Extra request headers",
"Sent with every upstream request for this provider, alongside the API key header.": "Sent with every upstream request for this provider, alongside the API key header.",
"Extra request body JSON is invalid.": "Extra request body JSON is invalid.",
"Extra request body must be a JSON object.": "Extra request body must be a JSON object.",
"Extra request headers JSON is invalid.": "Extra request headers JSON is invalid.",
"Extra request headers must be a JSON object.": "Extra request headers must be a JSON object.",4b37fa (feat(ui): edit extraBody and extraHeaders from Advanced settings)
"Auto detect protocols": "Auto detect protocols",
"Auto detect protocols description": "When enabled, CCR probes the endpoint while editing and uses the detected protocols and models to update this provider. Turn it off to keep manually selected protocols and custom model IDs unchanged.",
"Auto detect protocols info": "Auto detect protocols info",
@@ -932,6 +940,14 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Enter an OpenRouter API key to load providers.": "请输入 OpenRouter API key 后加载供应商。",
"Select a model to load its OpenRouter providers.": "请选择模型以加载它的 OpenRouter 供应商。",
"Excluded OpenRouter providers will be skipped by discount routing and OpenRouter fallbacks.": "黑名单中的 OpenRouter 供应商会被折扣路由和 OpenRouter fallback 跳过。",
"Extra request body": "附加请求体",
"Merged into every upstream request for this provider. Use \"default\" for all models, or \"byModel\" to target one.": "合并进发往该供应商的每个上游请求。\"default\" 对所有模型生效,\"byModel\" 可指定单个模型。",
"Extra request headers": "附加请求头",
"Sent with every upstream request for this provider, alongside the API key header.": "随发往该供应商的每个上游请求一起发送,与 API Key 请求头并存。",
"Extra request body JSON is invalid.": "附加请求体不是合法的 JSON。",
"Extra request body must be a JSON object.": "附加请求体必须是一个 JSON 对象。",
"Extra request headers JSON is invalid.": "附加请求头不是合法的 JSON。",
"Extra request headers must be a JSON object.": "附加请求头必须是一个 JSON 对象。",4b37fa (feat(ui): edit extraBody and extraHeaders from Advanced settings)
"Auto detect protocols": "自动探测协议",
"Auto detect protocols description": "开启后,CCR 会在编辑时探测接口,并用探测到的协议和模型更新此供应商。关闭后,手动选择的协议和自定义模型 ID 会保持不变。",
"Auto detect protocols info": "自动探测协议说明",
+56 -5
View File
@@ -645,6 +645,8 @@ export function createProviderDraftFromDeepLinkPayload(
catalogModelMetadata: undefined,
credentialMode: "apiKey",
credentials: [],
extraBodyText: "",
extraHeadersText: "",
icon: payload.icon?.trim() || "",
modelDescriptions: modelDescriptionsForModels(payload.modelDescriptions, models),
modelDisplayNames: modelDisplayNamesForModels(
@@ -743,6 +745,8 @@ export function createProviderDraft(providers: GatewayProviderConfig[]): AddProv
catalogModelMetadata: undefined,
credentialMode: "apiKey",
credentials: [],
extraBodyText: "",
extraHeadersText: "",
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
@@ -775,6 +779,8 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
catalogModelMetadata: undefined,
credentialMode: providerDraftHasReadyCredentialPool({ credentials }) ? "pool" : "apiKey",
credentials,
extraBodyText: providerExtraJsonDraftText(provider.extraBody),
extraHeadersText: providerExtraJsonDraftText(provider.extraHeaders),
icon: provider.icon ?? "",
modelDescriptions: modelDescriptionsForModels(provider.modelDescriptions, provider.models),
modelDisplayNames: modelDisplayNamesForModels(
@@ -1995,7 +2001,7 @@ export function providerCapabilitiesForSave(
export type ProviderManualFields = Pick<
GatewayProviderConfig,
"billing" | "extraBody" | "extraHeaders" | "provider" | "transformer"
"billing" | "provider" | "transformer"
>;
/**
@@ -2004,8 +2010,11 @@ export type ProviderManualFields = Pick<
* Saving rebuilds the provider from the dialog draft, so a field the form does
* not know about is dropped unless it is carried over explicitly. These fields
* only ever come from a hand-written config, and losing them is silent: the
* provider keeps working, just without the upstream body, headers or
* transformers it was configured with.
* provider keeps working, just without the billing metadata or transformers it
* was configured with.
*
* `extraBody` and `extraHeaders` are not listed here — the Advanced settings
* section edits them, so they round-trip through the draft instead.
*
* The legacy `apiKey` / `apikey` / `baseUrl` / `baseurl` aliases are deliberately
* not carried over — the form writes the canonical `api_key` / `api_base_url`,
@@ -2019,8 +2028,6 @@ export function providerManualFieldsForSave(
}
const preserved: ProviderManualFields = {
billing: existingProvider.billing,
extraBody: existingProvider.extraBody,
extraHeaders: existingProvider.extraHeaders,
provider: existingProvider.provider,
transformer: existingProvider.transformer
};
@@ -2034,6 +2041,50 @@ export function providerManualFieldsForSave(
return preserved;
}
export type ProviderExtraJsonField = "extraBody" | "extraHeaders";
const providerExtraJsonInvalidMessages: Record<ProviderExtraJsonField, string> = {
extraBody: "Extra request body JSON is invalid.",
extraHeaders: "Extra request headers JSON is invalid."
};
const providerExtraJsonShapeMessages: Record<ProviderExtraJsonField, string> = {
extraBody: "Extra request body must be a JSON object.",
extraHeaders: "Extra request headers must be a JSON object."
};
export function providerExtraJsonDraftText(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
return JSON.stringify(value, null, 2);
}
/**
* Parses one of the Advanced settings JSON boxes.
*
* Returns the parsed object, `undefined` when the box is empty, or an error
* message string the caller surfaces the same way as the other draft issues.
*/
export function parseProviderExtraJsonDraft(
text: string,
field: ProviderExtraJsonField
): Record<string, unknown> | undefined | string {
if (!text.trim()) {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return providerExtraJsonInvalidMessages[field];
}
if (!isPlainRecord(parsed)) {
return providerExtraJsonShapeMessages[field];
}
return parsed;
}
export function providerGlobalBaseUrlForProbe(
inputBaseUrl: string,
_probe: GatewayProviderProbeResult | undefined,
@@ -100,6 +100,8 @@ export type AddProviderDraft = {
catalogModelMetadata?: Record<string, ProviderModelMetadata>;
credentialMode: "apiKey" | "pool";
credentials: ProviderCredentialDraft[];
extraBodyText: string;
extraHeadersText: string;
icon: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
+43 -2
View File
@@ -29,6 +29,7 @@ import {
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
providerAutoFetchKnownModelsForSave,
parseProviderAccountDraft,
parseProviderExtraJsonDraft,
providerBrowserConnectorFromDraft,
providerGlobalBaseUrlForProbe,
providerManualFieldsForSave,
@@ -160,15 +161,55 @@ test("provider save keeps hand-written fields the dialog cannot edit", () => {
type: "openai_chat_completions"
};
// extraBody and extraHeaders are absent on purpose: the Advanced settings
// section edits them, so they round-trip through the draft instead.
assert.deepEqual(providerManualFieldsForSave(existing), {
billing: existing.billing,
extraBody: existing.extraBody,
extraHeaders: existing.extraHeaders,
provider: existing.provider,
transformer: existing.transformer
});
});
test("provider draft round-trips the advanced JSON boxes", () => {
const provider = {
api_base_url: "https://example.test/v1",
extraBody: { byModel: { "model-a": { reasoning_effort: "high" } } },
extraHeaders: { "x-tenant": "acme" },
models: ["model-a"],
name: "example"
};
const draft = createProviderDraftFromProvider(provider);
assert.deepEqual(parseProviderExtraJsonDraft(draft.extraBodyText, "extraBody"), provider.extraBody);
assert.deepEqual(parseProviderExtraJsonDraft(draft.extraHeadersText, "extraHeaders"), provider.extraHeaders);
});
test("provider draft leaves the advanced JSON boxes empty when unset", () => {
const draft = createProviderDraftFromProvider({ models: ["model-a"], name: "example" });
assert.equal(draft.extraBodyText, "");
assert.equal(draft.extraHeadersText, "");
assert.equal(parseProviderExtraJsonDraft(draft.extraBodyText, "extraBody"), undefined);
assert.equal(parseProviderExtraJsonDraft(draft.extraHeadersText, "extraHeaders"), undefined);
});
test("advanced JSON boxes reject malformed input instead of saving it", () => {
assert.equal(
parseProviderExtraJsonDraft("{ not json", "extraBody"),
"Extra request body JSON is invalid."
);
assert.equal(
parseProviderExtraJsonDraft("[1, 2]", "extraBody"),
"Extra request body must be a JSON object."
);
assert.equal(
parseProviderExtraJsonDraft("\"x-tenant\"", "extraHeaders"),
"Extra request headers must be a JSON object."
);
assert.equal(parseProviderExtraJsonDraft(" \n ", "extraBody"), undefined);
});
test("provider save drops legacy credential aliases so the edited values win", () => {
const existing = {
apiKey: "sk-legacy",
+62
View File
@@ -75,6 +75,68 @@ test("keeps config-only provider fields when the provider is saved from the dial
});
});
test("edits extraBody from the advanced settings section", async ({ page }) => {
const current = requireRuntime();
await page.goto(`${current.baseUrl}/?ccr_web_token=${current.token}`);
await waitForBridge(page);
await page.evaluate(async (provider) => {
const config = await window.ccr!.getConfig();
config.Providers = [provider];
await window.ccr!.saveConfig(config);
await window.ccr!.setOnboardingFinished?.();
}, configOnlyProvider);
await page.reload();
await waitForBridge(page);
await page.getByRole("button", { name: "Providers", exact: true }).click();
await page.locator(`button[aria-label="Edit ${configOnlyProvider.name}"]:visible`).first().click();
await page.getByRole("button", { name: /^(Advanced settings|高级设置)$/ }).click();
// The box opens pre-filled with what the config already carries.
const extraBodyBox = page.getByLabel(/Extra request body|附加请求体/);
await expect(extraBodyBox).toHaveValue(JSON.stringify(configOnlyProvider.extraBody, null, 2));
await extraBodyBox.fill('{ "default": { "reasoning_effort": "max" } }');
const saveButton = page.getByRole("button", { name: /^(Save|保存)$/ });
await saveButton.click();
await expect(saveButton).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const config = await window.ccr!.getConfig();
return config.Providers[0]?.extraBody;
})).toEqual({ default: { reasoning_effort: "max" } });
});
test("refuses to save a malformed advanced JSON box", async ({ page }) => {
const current = requireRuntime();
await page.goto(`${current.baseUrl}/?ccr_web_token=${current.token}`);
await waitForBridge(page);
await page.evaluate(async (provider) => {
const config = await window.ccr!.getConfig();
config.Providers = [provider];
await window.ccr!.saveConfig(config);
await window.ccr!.setOnboardingFinished?.();
}, configOnlyProvider);
await page.reload();
await waitForBridge(page);
await page.getByRole("button", { name: "Providers", exact: true }).click();
await page.locator(`button[aria-label="Edit ${configOnlyProvider.name}"]:visible`).first().click();
await page.getByRole("button", { name: /^(Advanced settings|高级设置)$/ }).click();
await page.getByLabel(/Extra request body|附加请求体/).fill("{ not json");
await page.getByRole("button", { name: /^(Save|保存)$/ }).click();
await expect(page.getByText(/Extra request body JSON is invalid|附加请求体不是合法的 JSON/)).toBeVisible();
await expect.poll(async () => page.evaluate(async () => {
const config = await window.ccr!.getConfig();
return config.Providers[0]?.extraBody;
})).toEqual(configOnlyProvider.extraBody);
});
async function waitForBridge(page: Page): Promise<void> {
await page.waitForFunction(() => Boolean(window.ccr?.getConfig), undefined, { timeout: 20_000 });
}