feat: add provider-native request switches (#5254)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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":[]}',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user