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
@@ -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), {