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(
+13 -7
View File
@@ -4,7 +4,7 @@ import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
import {
mergeToolProgressEvents,
mergeUniqueToolTraceLines,
mergeToolProgressTraceLines,
normalizeToolProgressEvents,
toolTraceLinesFromEvents,
} from "@/lib/tool-traces";
@@ -1213,18 +1213,24 @@ export function useNanobotStream(
: last.content
? [last.content]
: [];
const mergedEvents = visibleStructuredEvents.length > 0
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents;
const mergedLines = visibleStructuredEvents.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
? mergeToolProgressTraceLines(
previousTraces,
last.toolEvents,
structuredLines,
visibleStructuredEvents,
)
: null;
const merged: UIMessage = {
...last,
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
traces: mergedLines ?? [...previousTraces, ...lines],
content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1]
? mergedLines[mergedLines.length - 1]
: lines[lines.length - 1],
toolEvents: visibleStructuredEvents.length
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents,
toolEvents: mergedEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
...turn,
};
+9 -1
View File
@@ -514,7 +514,15 @@
"profile": "Profile",
"extraHeaders": "Extra headers",
"extraBody": "Extra body",
"extraQuery": "Extra query"
"extraQuery": "Extra query",
"capabilityFastMode": "Fast mode",
"capabilityFastModeHelp": "Use OpenAI's priority service tier for faster responses. This consumes credits faster.",
"capabilityDeepSeekSearch": "DeepSeek web search",
"capabilityDeepSeekSearchHelp": "Let DeepSeek V4 Flash search the web through its Responses API. Search activity appears in chat.",
"capabilityXSearch": "X Search",
"capabilityXSearchHelp": "Allow supported Grok models to use xAI-hosted X Search. Search activity appears in chat.",
"capabilityOpenAISearch": "OpenAI web search",
"capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat."
},
"legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
+9 -1
View File
@@ -365,7 +365,15 @@
"profile": "Perfil",
"extraHeaders": "Cabeceras adicionales",
"extraBody": "Cuerpo adicional",
"extraQuery": "Parámetros de consulta adicionales"
"extraQuery": "Parámetros de consulta adicionales",
"capabilityFastMode": "Modo rápido",
"capabilityFastModeHelp": "Usa el nivel de servicio prioritario de OpenAI para responder más rápido. Esto consume créditos más deprisa.",
"capabilityDeepSeekSearch": "Búsqueda web de DeepSeek",
"capabilityDeepSeekSearchHelp": "Permite que DeepSeek V4 Flash busque en la web mediante Responses API. La actividad de búsqueda aparece en el chat.",
"capabilityXSearch": "Búsqueda en X",
"capabilityXSearchHelp": "Permite que los modelos Grok compatibles usen X Search alojado por xAI. La actividad de búsqueda aparece en el chat.",
"capabilityOpenAISearch": "Búsqueda web de OpenAI",
"capabilityOpenAISearchHelp": "Permite que los modelos compatibles con Responses API busquen en la web. La actividad de búsqueda aparece en el chat."
},
"image": {
"selectProvider": "Seleccionar proveedor",
+9 -1
View File
@@ -365,7 +365,15 @@
"profile": "Profil",
"extraHeaders": "En-têtes supplémentaires",
"extraBody": "Corps supplémentaire",
"extraQuery": "Paramètres de requête supplémentaires"
"extraQuery": "Paramètres de requête supplémentaires",
"capabilityFastMode": "Mode rapide",
"capabilityFastModeHelp": "Utilise le niveau de service prioritaire dOpenAI pour accélérer les réponses. Les crédits sont consommés plus rapidement.",
"capabilityDeepSeekSearch": "Recherche web DeepSeek",
"capabilityDeepSeekSearchHelp": "Permet à DeepSeek V4 Flash de rechercher sur le web via lAPI Responses. Lactivité de recherche apparaît dans le chat.",
"capabilityXSearch": "Recherche sur X",
"capabilityXSearchHelp": "Permet aux modèles Grok compatibles dutiliser X Search hébergé par xAI. Lactivité de recherche apparaît dans le chat.",
"capabilityOpenAISearch": "Recherche web OpenAI",
"capabilityOpenAISearchHelp": "Permet aux modèles compatibles avec lAPI Responses de rechercher sur le web. Lactivité de recherche apparaît dans le chat."
},
"image": {
"selectProvider": "Choisir un fournisseur",
+9 -1
View File
@@ -365,7 +365,15 @@
"profile": "Profil",
"extraHeaders": "Header tambahan",
"extraBody": "Body tambahan",
"extraQuery": "Parameter kueri tambahan"
"extraQuery": "Parameter kueri tambahan",
"capabilityFastMode": "Mode cepat",
"capabilityFastModeHelp": "Gunakan tingkat layanan prioritas OpenAI agar respons lebih cepat. Kredit akan terpakai lebih cepat.",
"capabilityDeepSeekSearch": "Pencarian web DeepSeek",
"capabilityDeepSeekSearchHelp": "Izinkan DeepSeek V4 Flash mencari di web melalui Responses API. Aktivitas pencarian ditampilkan di chat.",
"capabilityXSearch": "Pencarian X",
"capabilityXSearchHelp": "Izinkan model Grok yang didukung menggunakan X Search yang dihosting xAI. Aktivitas pencarian ditampilkan di chat.",
"capabilityOpenAISearch": "Pencarian web OpenAI",
"capabilityOpenAISearchHelp": "Izinkan model yang kompatibel dengan Responses API mencari di web. Aktivitas pencarian ditampilkan di chat."
},
"image": {
"selectProvider": "Pilih penyedia",
+9 -1
View File
@@ -365,7 +365,15 @@
"profile": "プロファイル",
"extraHeaders": "追加ヘッダー",
"extraBody": "追加ボディ",
"extraQuery": "追加クエリ"
"extraQuery": "追加クエリ",
"capabilityFastMode": "高速モード",
"capabilityFastModeHelp": "OpenAI の優先サービス階層を使用して応答を高速化します。クレジットの消費も速くなります。",
"capabilityDeepSeekSearch": "DeepSeek ウェブ検索",
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash が Responses API 経由でウェブを検索できるようにします。検索状況はチャットに表示されます。",
"capabilityXSearch": "X 検索",
"capabilityXSearchHelp": "対応する Grok モデルが xAI ホストの X Search を使用できるようにします。検索状況はチャットに表示されます。",
"capabilityOpenAISearch": "OpenAI ウェブ検索",
"capabilityOpenAISearchHelp": "Responses API 対応モデルがウェブを検索できるようにします。検索状況はチャットに表示されます。"
},
"image": {
"selectProvider": "プロバイダーを選択",
+9 -1
View File
@@ -365,7 +365,15 @@
"profile": "프로필",
"extraHeaders": "추가 헤더",
"extraBody": "추가 본문",
"extraQuery": "추가 쿼리"
"extraQuery": "추가 쿼리",
"capabilityFastMode": "고속 모드",
"capabilityFastModeHelp": "OpenAI 우선 서비스 등급을 사용해 더 빠르게 응답합니다. 크레딧도 더 빠르게 소모됩니다.",
"capabilityDeepSeekSearch": "DeepSeek 웹 검색",
"capabilityDeepSeekSearchHelp": "DeepSeek V4 Flash가 Responses API를 통해 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
"capabilityXSearch": "X 검색",
"capabilityXSearchHelp": "지원되는 Grok 모델이 xAI 호스팅 X Search를 사용하도록 허용합니다. 검색 활동은 채팅에 표시됩니다.",
"capabilityOpenAISearch": "OpenAI 웹 검색",
"capabilityOpenAISearchHelp": "Responses API 호환 모델이 웹을 검색하도록 허용합니다. 검색 활동은 채팅에 표시됩니다."
},
"image": {
"selectProvider": "제공자 선택",
+9 -1
View File
@@ -514,7 +514,15 @@
"profile": "Perfil",
"extraHeaders": "Cabeçalhos adicionais",
"extraBody": "Corpo adicional",
"extraQuery": "Parâmetros de consulta adicionais"
"extraQuery": "Parâmetros de consulta adicionais",
"capabilityFastMode": "Modo rápido",
"capabilityFastModeHelp": "Usa o nível de serviço prioritário da OpenAI para respostas mais rápidas. Isso consome créditos mais rapidamente.",
"capabilityDeepSeekSearch": "Pesquisa web do DeepSeek",
"capabilityDeepSeekSearchHelp": "Permite que o DeepSeek V4 Flash pesquise na web pela Responses API. A atividade de pesquisa aparece no chat.",
"capabilityXSearch": "Pesquisa no X",
"capabilityXSearchHelp": "Permite que modelos Grok compatíveis usem o X Search hospedado pela xAI. A atividade de pesquisa aparece no chat.",
"capabilityOpenAISearch": "Pesquisa web da OpenAI",
"capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat."
},
"legal": {
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."
+9 -1
View File
@@ -365,7 +365,15 @@
"profile": "Hồ sơ",
"extraHeaders": "Header bổ sung",
"extraBody": "Body bổ sung",
"extraQuery": "Tham số truy vấn bổ sung"
"extraQuery": "Tham số truy vấn bổ sung",
"capabilityFastMode": "Chế độ nhanh",
"capabilityFastModeHelp": "Dùng tầng dịch vụ ưu tiên của OpenAI để phản hồi nhanh hơn. Tín dụng cũng sẽ được tiêu thụ nhanh hơn.",
"capabilityDeepSeekSearch": "Tìm kiếm web DeepSeek",
"capabilityDeepSeekSearchHelp": "Cho phép DeepSeek V4 Flash tìm kiếm trên web qua Responses API. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
"capabilityXSearch": "Tìm kiếm X",
"capabilityXSearchHelp": "Cho phép các mô hình Grok được hỗ trợ dùng X Search do xAI lưu trữ. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện.",
"capabilityOpenAISearch": "Tìm kiếm web OpenAI",
"capabilityOpenAISearchHelp": "Cho phép các mô hình tương thích với Responses API tìm kiếm trên web. Hoạt động tìm kiếm được hiển thị trong cuộc trò chuyện."
},
"image": {
"selectProvider": "Chọn nhà cung cấp",
+9 -1
View File
@@ -514,7 +514,15 @@
"profile": "配置档案",
"extraHeaders": "额外请求头",
"extraBody": "额外请求体",
"extraQuery": "额外查询参数"
"extraQuery": "额外查询参数",
"capabilityFastMode": "Fast 模式",
"capabilityFastModeHelp": "通过 OpenAI 优先服务层获取更快响应,会更快消耗额度。",
"capabilityDeepSeekSearch": "DeepSeek 联网搜索",
"capabilityDeepSeekSearchHelp": "允许 DeepSeek V4 Flash 通过 Responses API 搜索网络,并在对话中展示搜索过程。",
"capabilityXSearch": "X 搜索",
"capabilityXSearchHelp": "允许受支持的 Grok 模型使用 xAI 托管的 X Search,并在对话中展示搜索过程。",
"capabilityOpenAISearch": "OpenAI 联网搜索",
"capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。"
},
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
+9 -1
View File
@@ -365,7 +365,15 @@
"profile": "設定檔",
"extraHeaders": "額外請求標頭",
"extraBody": "額外請求本文",
"extraQuery": "額外查詢參數"
"extraQuery": "額外查詢參數",
"capabilityFastMode": "Fast 模式",
"capabilityFastModeHelp": "透過 OpenAI 優先服務層取得更快回應,也會更快消耗額度。",
"capabilityDeepSeekSearch": "DeepSeek 網路搜尋",
"capabilityDeepSeekSearchHelp": "允許 DeepSeek V4 Flash 透過 Responses API 搜尋網路,並在對話中顯示搜尋過程。",
"capabilityXSearch": "X 搜尋",
"capabilityXSearchHelp": "允許支援的 Grok 模型使用 xAI 託管的 X Search,並在對話中顯示搜尋過程。",
"capabilityOpenAISearch": "OpenAI 網路搜尋",
"capabilityOpenAISearchHelp": "允許相容 Responses API 的模型搜尋網路,並在對話中顯示搜尋過程。"
},
"image": {
"selectProvider": "選擇供應商",
+25 -1
View File
@@ -16,7 +16,10 @@ export function formatToolCallTrace(call: unknown): string | null {
if (!name) return null;
const args = item.function?.arguments ?? item.arguments;
if (typeof args === "string" && args.trim()) return `${name}(${args})`;
if (args && typeof args === "object") return `${name}(${JSON.stringify(args)})`;
if (args && typeof args === "object") {
const serialized = JSON.stringify(args);
return serialized === "{}" || serialized === "[]" ? `${name}()` : `${name}(${serialized})`;
}
return `${name}()`;
}
@@ -116,3 +119,24 @@ export function mergeUniqueToolTraceLines(
}
return { traces, added };
}
export function mergeToolProgressTraceLines(
previousTraces: string[],
previousEvents: ToolProgressEvent[] | undefined,
incomingTraces: string[],
incomingEvents: ToolProgressEvent[],
): string[] {
const mergedEvents = mergeToolProgressEvents(previousEvents, incomingEvents);
const candidates = mergeUniqueToolTraceLines(previousTraces, incomingTraces).traces;
const eventTraceKeys = new Set([
...toolTraceLinesFromEvents(previousEvents),
...toolTraceLinesFromEvents(incomingEvents),
].map(canonicalToolTrace));
const nonEventTraces = candidates.filter(
(line) => !eventTraceKeys.has(canonicalToolTrace(line)),
);
return mergeUniqueToolTraceLines(
nonEventTraces,
toolTraceLinesFromEvents(mergedEvents),
).traces;
}
@@ -1165,12 +1165,13 @@ describe("AgentActivityCluster", () => {
id: "search-start",
role: "tool",
kind: "trace",
content: line,
traces: [line],
content: "web_search()",
traces: ["web_search()"],
toolEvents: [{
phase: "start",
call_id: "hosted-search-1",
name: "web_search",
arguments: { query: "site:linkedin.com/company Evomap startup" },
arguments: {},
}],
createdAt: 1,
},
@@ -1182,6 +1183,7 @@ describe("AgentActivityCluster", () => {
traces: [line],
toolEvents: [{
phase: "error",
call_id: "hosted-search-1",
name: "web_search",
arguments: { query: "site:linkedin.com/company Evomap startup" },
error: "Search provider rate limited the request",
+2 -2
View File
@@ -580,7 +580,7 @@ describe("webui API helpers", () => {
await updateProviderSettings("tok", {
provider: "xai_grok",
proxy: "http://127.0.0.1:7890",
extraBody: '{"service_tier":"priority"}',
extraBody: '{"tools":[]}',
});
expect(fetch).toHaveBeenCalledWith(
@@ -590,7 +590,7 @@ describe("webui API helpers", () => {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
proxy: "http://127.0.0.1:7890",
extraBody: '{"service_tier":"priority"}',
extraBody: '{"tools":[]}',
})),
},
}),
+194
View File
@@ -3101,6 +3101,200 @@ describe("SettingsView Apps catalog", () => {
);
});
it("maps provider request switches to raw extraBody fields", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "grok@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
oauth_account: "codex@example.com",
oauth_login_supported: true,
advanced_fields: ["extra_body", "proxy"],
extra_body: null,
},
{
name: "deepseek",
label: "DeepSeek",
configured: true,
api_key_required: true,
api_key_hint: "deep••••test",
api_base: "https://api.deepseek.com",
advanced_fields: ["extra_body"],
extra_body: null,
},
{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: null,
},
];
const payload: SettingsPayload = { ...base, providers };
const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => {
const [input] = args;
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
const xSearch = screen.getByRole("switch", { name: "X Search" });
expect(xSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(xSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok",
expect.anything(),
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "xAI Grok" }));
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("switch", { name: "Fast mode" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=openai_codex",
expect.anything(),
));
await waitFor(() => expect(
screen.getByRole("button", { name: "Save provider" }),
).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" }));
fireEvent.click(screen.getByRole("button", { name: /^DeepSeek/ }));
expect(screen.getByText(/DeepSeek V4 Flash/)).toBeInTheDocument();
const deepSeekSearch = screen.getByRole("switch", { name: "DeepSeek web search" });
expect(deepSeekSearch).toHaveAttribute("aria-checked", "true");
fireEvent.click(deepSeekSearch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "DeepSeek web search" }),
).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^OpenAI https:/ }));
fireEvent.click(screen.getByRole("switch", { name: "OpenAI web search" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => expect(
screen.queryByRole("switch", { name: "OpenAI web search" }),
).not.toBeInTheDocument());
await waitFor(() => {
const requestUpdates = fetchMock.mock.calls
.filter(([input]) => String(input).startsWith("/api/settings/provider/update?"))
.map(([input, init]) => {
const provider = new URLSearchParams(String(input).split("?")[1]).get("provider");
const headers = init?.headers as Record<string, string>;
const values = JSON.parse(decodeURIComponent(
headers["X-Nanobot-Provider-Values"],
)) as { apiType?: string; extraBody?: string };
return [provider, {
...(values.apiType ? { apiType: values.apiType } : {}),
extraBody: JSON.parse(values.extraBody ?? "{}"),
}] as const;
});
expect(requestUpdates).toEqual([
["xai_grok", { extraBody: { tools: [] } }],
["openai_codex", { extraBody: { service_tier: "priority" } }],
["deepseek", { extraBody: { tools: [] } }],
["openai", {
apiType: "responses",
extraBody: { tools: [{ type: "web_search" }] },
}],
]);
});
});
it("recognizes and removes versioned web search tools without losing raw settings", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
providers: [{
name: "openai",
label: "OpenAI",
configured: true,
api_key_required: true,
api_key_hint: "sk-••••test",
api_base: "https://api.openai.com/v1",
api_type: "auto",
advanced_fields: ["api_type", "extra_body"],
extra_body: {
metadata: { owner: "legacy-config" },
tools: [
{ type: "web_search_preview", search_context_size: "medium" },
{ type: "file_search", vector_store_ids: ["vs_legacy"] },
],
},
}],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
fireEvent.click(await screen.findByRole("button", { name: /^OpenAI https:/ }));
const searchSwitch = screen.getByRole("switch", { name: "OpenAI web search" });
expect(searchSwitch).toHaveAttribute("aria-checked", "true");
fireEvent.click(searchSwitch);
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
const updateCall = fetchMock.mock.calls.find(
([input]) => String(input).startsWith("/api/settings/provider/update?"),
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
const values = JSON.parse(decodeURIComponent(
headers["X-Nanobot-Provider-Values"],
)) as { extraBody: string };
expect(JSON.parse(values.extraBody)).toEqual({
metadata: { owner: "legacy-config" },
tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }],
});
});
});
it("creates a custom provider with folded advanced request settings", async () => {
const base = settingsPayload();
let payload: SettingsPayload = {
+15
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
canonicalToolTrace,
mergeToolProgressTraceLines,
mergeUniqueToolTraceLines,
} from "@/lib/tool-traces";
@@ -26,4 +27,18 @@ describe("tool trace identity", () => {
added: true,
});
});
it("replaces an empty streaming placeholder when hosted search arguments arrive", () => {
expect(mergeToolProgressTraceLines(
["web_search()"],
[{ phase: "start", call_id: "ws-1", name: "web_search", arguments: {} }],
['web_search({"query":"nanobot news"})'],
[{
phase: "end",
call_id: "ws-1",
name: "web_search",
arguments: { query: "nanobot news" },
}],
)).toEqual(['web_search({"query":"nanobot news"})']);
});
});
@@ -8,6 +8,10 @@ function describeTrace(line: string, status: GenericToolStatus = "done") {
}
describe("trace activity semantics", () => {
it("uses readable copy when a hosted search query is unavailable", () => {
expect(describeTrace("web_search()").label).toBe("Searched the web");
});
it.each([
['web_search({"query":"nanobot latest release"})', "done", "Searched nanobot latest release", ""],
['web_fetch({"url":"https://example.com/docs?token=private"})', "done", "Read", "example.com/docs"],
+45
View File
@@ -610,6 +610,51 @@ describe("useNanobotStream", () => {
]);
});
it("replaces a hosted search placeholder when its query arrives", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-hosted-search", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-hosted-search", {
event: "message",
chat_id: "chat-hosted-search",
text: "web_search()",
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "ws-1",
name: "web_search",
arguments: {},
}],
});
fake.emit("chat-hosted-search", {
event: "message",
chat_id: "chat-hosted-search",
text: "",
kind: "progress",
tool_events: [{
phase: "end",
call_id: "ws-1",
name: "web_search",
arguments: { query: "nanobot news" },
result: { status: "completed" },
}],
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].traces).toEqual([
'web_search({"query":"nanobot news"})',
]);
expect(result.current.messages[0].toolEvents).toMatchObject([{
phase: "end",
call_id: "ws-1",
arguments: { query: "nanobot news" },
}]);
});
it("keeps phase updates when a tool event trace line is deduped", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {