feat: add provider-native request switches (#5254)

This commit is contained in:
chengyongru
2026-08-05 18:26:39 +08:00
committed by GitHub
parent 5a1ab44baa
commit 67805f5db8
32 changed files with 1130 additions and 52 deletions
+181 -5
View File
@@ -276,6 +276,52 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const;
const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]);
type ProviderRequestOption = {
kind: "priority" | "hosted_tool";
titleKey: string;
title: string;
helpKey: string;
help: string;
toolType?: "web_search" | "x_search";
defaultEnabled?: boolean;
forceResponses?: boolean;
};
const PROVIDER_REQUEST_OPTIONS: Partial<Record<string, ProviderRequestOption[]>> = {
openai_codex: [{
kind: "priority",
titleKey: "settings.providers.capabilityFastMode",
title: "Fast mode",
helpKey: "settings.providers.capabilityFastModeHelp",
help: "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
}],
openai: [{
kind: "hosted_tool",
titleKey: "settings.providers.capabilityOpenAISearch",
title: "OpenAI web search",
helpKey: "settings.providers.capabilityOpenAISearchHelp",
help: "Allow compatible Responses API models to search the web. Search activity appears in chat.",
toolType: "web_search",
forceResponses: true,
}],
deepseek: [{
kind: "hosted_tool",
titleKey: "settings.providers.capabilityDeepSeekSearch",
title: "DeepSeek web search",
helpKey: "settings.providers.capabilityDeepSeekSearchHelp",
help: "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
toolType: "web_search",
defaultEnabled: true,
}],
xai_grok: [{
kind: "hosted_tool",
titleKey: "settings.providers.capabilityXSearch",
title: "X Search",
helpKey: "settings.providers.capabilityXSearchHelp",
help: "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
toolType: "x_search",
defaultEnabled: true,
}],
};
const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__";
const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [
"extra_headers",
@@ -306,6 +352,70 @@ function providerJsonValue(value: Record<string, unknown> | null | undefined): s
return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : "";
}
function parseProviderExtraBody(value: string): Record<string, unknown> | null {
if (!value.trim()) return {};
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function isHostedSearchTool(tool: unknown, toolType: "web_search" | "x_search"): boolean {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
const configuredType = (tool as Record<string, unknown>).type;
if (typeof configuredType !== "string") return false;
return configuredType === toolType
|| (toolType === "web_search" && configuredType.startsWith("web_search_"));
}
function hasHostedSearchTool(value: unknown, toolType: "web_search" | "x_search"): boolean {
return Array.isArray(value) && value.some((tool) => isHostedSearchTool(tool, toolType));
}
function providerRequestOptionEnabled(
option: ProviderRequestOption,
extraBody: Record<string, unknown>,
): boolean {
if (option.kind === "priority") return extraBody.service_tier === "priority";
if (Object.prototype.hasOwnProperty.call(extraBody, "tools")) {
return hasHostedSearchTool(extraBody.tools, option.toolType!);
}
return option.defaultEnabled === true;
}
function updateProviderRequestOption(
option: ProviderRequestOption,
enabled: boolean,
form: ProviderForm,
): Partial<ProviderForm> {
const extraBody = { ...(parseProviderExtraBody(form.extraBody) ?? {}) };
if (option.kind === "priority") {
if (enabled) extraBody.service_tier = "priority";
else if (extraBody.service_tier === "priority") delete extraBody.service_tier;
} else {
const toolType = option.toolType!;
const tools = Array.isArray(extraBody.tools)
? extraBody.tools.filter((tool) => !isHostedSearchTool(tool, toolType))
: [];
if (enabled) tools.push({ type: toolType });
if (tools.length || option.defaultEnabled) {
extraBody.tools = tools;
} else {
delete extraBody.tools;
}
}
return {
extraBody: providerJsonValue(extraBody),
...(option.forceResponses && enabled
? { apiType: "responses" as const }
: {}),
};
}
function providerFormFromRow(
provider: SettingsPayload["providers"][number],
): ProviderForm {
@@ -3852,6 +3962,62 @@ function ModelAdvancedFields({
);
}
function ProviderRequestOptions({
providerName,
form,
onChange,
}: {
providerName: string;
form: ProviderForm;
onChange: (value: Partial<ProviderForm>) => void;
}) {
const { t } = useTranslation();
const options = PROVIDER_REQUEST_OPTIONS[providerName] ?? [];
if (options.length === 0) return null;
const extraBody = parseProviderExtraBody(form.extraBody) ?? {};
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="overflow-hidden rounded-[18px] border border-border/45 bg-background/75">
{options.map((option, index) => {
const title = tx(option.titleKey, option.title);
const Icon = option.kind === "priority" ? Zap : Globe2;
const checked = providerRequestOptionEnabled(option, extraBody);
return (
<div
key={option.titleKey}
className={cn(
"flex items-center justify-between gap-4 px-4 py-3",
index > 0 && "border-t border-border/45",
)}
>
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground">
<Icon className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">{title}</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{tx(option.helpKey, option.help)}
</p>
</div>
</div>
<ToggleButton
checked={checked}
onChange={(enabled) => onChange(
updateProviderRequestOption(option, enabled, form),
)}
ariaLabel={title}
label={checked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</div>
);
})}
</div>
);
}
function ProviderAdvancedOptions({
fields,
form,
@@ -4065,11 +4231,11 @@ function ProviderAdvancedOptions({
</label>
) : null}
</div>
{footer ? (
<div className="mt-3 flex items-center justify-end gap-2 border-t border-border/45 pt-3">
{footer}
</div>
) : null}
</div>
) : null}
{footer ? (
<div className="flex items-center justify-end gap-2 border-t border-border/45 py-3">
{footer}
</div>
) : null}
</div>
@@ -4320,6 +4486,11 @@ function ProvidersSettings({
</Button>
</div>
</div>
<ProviderRequestOptions
providerName={provider.name}
form={form}
onChange={(value) => onChangeProviderForm(provider.name, value)}
/>
{supportsOauthAdvancedSettings ? (
<ProviderAdvancedOptions
fields={advancedFields}
@@ -4445,6 +4616,11 @@ function ProvidersSettings({
className="h-9 rounded-full text-[13px]"
/>
</label>
<ProviderRequestOptions
providerName={provider.name}
form={form}
onChange={(value) => onChangeProviderForm(provider.name, value)}
/>
<ProviderAdvancedOptions
fields={advancedFields}
form={form}
@@ -1,6 +1,7 @@
import {
canonicalToolTrace,
mergeToolProgressEvents,
mergeToolProgressTraceLines,
mergeUniqueToolTraceLines,
} from "@/lib/tool-traces";
import type { UIMediaAttachment, UIMessage } from "@/lib/types";
@@ -56,8 +57,15 @@ function canMergeAdjacentProgress(
}
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
const traces = mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []);
const traces = incoming.toolEvents?.length
? mergeToolProgressTraceLines(
messageTraces(previous),
previous.toolEvents,
messageTraces(incoming),
incoming.toolEvents ?? [],
)
: mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])];
const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);
@@ -27,7 +27,7 @@ export function describeTraceLine(
!!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed);
if (/search/i.test(name)) {
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args || trimmed;
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args;
return {
kind: "search",
label: presentWebSearchAction(query, status, name === "x_search" ? "x" : "web"),
@@ -105,7 +105,7 @@ export function presentWebSearchAction(
if (target === "x") {
return queryTarget ? `${verb} X · ${queryTarget}` : `${verb} X`;
}
return queryTarget ? `${verb} ${queryTarget}` : verb;
return queryTarget ? `${verb} ${queryTarget}` : `${verb} the web`;
}
function mergeWebSearchRun(