feat(ui): set a search tool's cost per query from the Admin UI (#46723)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-15 15:13:14 -07:00 • committed by GitHub
parent 67fab6737b
commit 16abb004a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 193 additions and 2 deletions

View file

@ -54,6 +54,7 @@ describe("CreateSearchTools submit payload", () => {
fireEvent.change(screen.getByLabelText(/Search Tool Name/), { target: { value: "my-search" } });
await pickProvider(user, "Perplexity AI");
fireEvent.change(screen.getByLabelText(/API Key/), { target: { value: "sk-secret" } });
fireEvent.change(screen.getByLabelText(/Cost per Query/), { target: { value: "0.05" } });
fireEvent.change(screen.getByLabelText(/Description/), { target: { value: "finds things" } });
await user.click(screen.getByRole("button", { name: "Add Search Tool" }));
@ -65,14 +66,45 @@ describe("CreateSearchTools submit payload", () => {
litellm_params: {
search_provider: "perplexity",
api_key: "sk-secret",
input_cost_per_query: 0.05,
},
search_tool_info: { description: "finds things" },
});
expect(JSON.stringify(payload)).toBe(
'{"search_tool_name":"my-search","litellm_params":{"search_provider":"perplexity","api_key":"sk-secret"},"search_tool_info":{"description":"finds things"}}',
'{"search_tool_name":"my-search","litellm_params":{"search_provider":"perplexity","api_key":"sk-secret","input_cost_per_query":0.05},"search_tool_info":{"description":"finds things"}}',
);
});
it("sends a zero cost per query as 0, not as an omitted field", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByLabelText(/Search Tool Name/);
fireEvent.change(screen.getByLabelText(/Search Tool Name/), { target: { value: "free-search" } });
await pickProvider(user, "Tavily Search");
fireEvent.change(screen.getByLabelText(/Cost per Query/), { target: { value: "0" } });
await user.click(screen.getByRole("button", { name: "Add Search Tool" }));
await waitFor(() => expect(networking.createSearchTool).toHaveBeenCalledTimes(1));
expect(JSON.stringify(vi.mocked(networking.createSearchTool).mock.calls[0][1])).toBe(
'{"search_tool_name":"free-search","litellm_params":{"search_provider":"tavily","input_cost_per_query":0}}',
);
});
it("rejects a negative cost per query before anything is sent", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByLabelText(/Search Tool Name/);
fireEvent.change(screen.getByLabelText(/Search Tool Name/), { target: { value: "neg-search" } });
await pickProvider(user, "Tavily Search");
fireEvent.change(screen.getByLabelText(/Cost per Query/), { target: { value: "-1" } });
await user.click(screen.getByRole("button", { name: "Add Search Tool" }));
expect(await screen.findByText("Cost per query cannot be negative")).toBeInTheDocument();
expect(networking.createSearchTool).not.toHaveBeenCalled();
});
it("omits untouched optional fields from the wire body instead of sending empty strings", async () => {
const user = userEvent.setup();
renderModal();

View file

@ -71,6 +71,7 @@ const createSearchToolShape = {
.pipe(z.string({ error: "Please select a search provider" }).min(1, "Please select a search provider")),
api_key: z.string().optional(),
description: z.string().optional(),
input_cost_per_query: z.number().nonnegative("Cost per query cannot be negative").optional(),
};
const createSearchToolSchema = z.object(createSearchToolShape);
@ -267,6 +268,28 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
)}
</FormField>
<FormField
control={form.control}
name="input_cost_per_query"
label={labelWithHint(
"Cost per Query (USD, Optional)",
"Your negotiated price per search query. It overrides the LiteLLM cost map price for this tool in the response cost header and spend logs. Leave blank to use the provider's default price.",
)}
>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step="any"
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? undefined : event.target.valueAsNumber)}
placeholder="e.g., 0.007"
className="rounded-lg"
/>
)}
</FormField>
<FormField control={form.control} name="description" label="Description (Optional)">
{({ ref, value, ...field }) => (
<Textarea

View file

@ -105,6 +105,32 @@ describe("SearchToolView", () => {
expect(screen.getByText("Not set")).toBeInTheDocument();
});
it("shows the configured cost per query as a dollar amount", () => {
const pricedTool: SearchTool = {
...mockSearchTool,
litellm_params: { search_provider: "perplexity", input_cost_per_query: 0.05 },
};
render(<SearchToolView {...defaultProps} searchTool={pricedTool} />);
expect(screen.getByText("$0.05")).toBeInTheDocument();
});
it("shows a zero cost per query as $0 rather than as the provider default", () => {
const freeTool: SearchTool = {
...mockSearchTool,
litellm_params: { search_provider: "perplexity", input_cost_per_query: 0 },
};
render(<SearchToolView {...defaultProps} searchTool={freeTool} />);
expect(screen.getByText("$0")).toBeInTheDocument();
expect(screen.queryByText("Provider default")).not.toBeInTheDocument();
});
it("shows 'Provider default' when no cost per query is configured", () => {
render(<SearchToolView {...defaultProps} />);
expect(screen.getByText("Provider default")).toBeInTheDocument();
});
it("should display formatted created_at date", () => {
render(<SearchToolView {...defaultProps} />);
const dateText = screen.getByText(/2024-01-15/);

View file

@ -90,6 +90,17 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Cost per Query</p>
<p className="mt-2 text-foreground">
{typeof searchTool.litellm_params.input_cost_per_query === "number"
? `$${searchTool.litellm_params.input_cost_per_query}`
: "Provider default"}
</p>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Created At</p>

View file

@ -33,6 +33,7 @@ const toolWithServerOnlyParams: SearchTool = {
api_base: "https://api.example.com",
timeout: 30,
max_retries: 2,
input_cost_per_query: 0.05,
},
search_tool_info: { description: "Test description" },
created_at: "2024-01-15T10:30:00Z",
@ -95,14 +96,70 @@ describe("SearchTools edit payload", () => {
litellm_params: {
search_provider: "perplexity",
api_key: "sk-test-key",
input_cost_per_query: 0.05,
},
search_tool_info: { description: "Test description" },
});
expect(JSON.stringify(payload)).toBe(
'{"search_tool_name":"Perplexity Search","litellm_params":{"search_provider":"perplexity","api_key":"sk-test-key"},"search_tool_info":{"description":"Test description"}}',
'{"search_tool_name":"Perplexity Search","litellm_params":{"search_provider":"perplexity","api_key":"sk-test-key","input_cost_per_query":0.05},"search_tool_info":{"description":"Test description"}}',
);
});
it("shows the stored cost per query in the edit form and sends the new value typed into it", async () => {
const user = userEvent.setup();
renderPage();
await openEditModal(user);
expect(screen.getByLabelText("Cost per Query (USD)")).toHaveValue(0.05);
fireEvent.change(screen.getByLabelText("Cost per Query (USD)"), { target: { value: "0.007" } });
await user.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(networking.updateSearchTool).toHaveBeenCalledTimes(1));
expect(vi.mocked(networking.updateSearchTool).mock.calls[0][2]).toMatchObject({
litellm_params: { search_provider: "perplexity", api_key: "sk-test-key", input_cost_per_query: 0.007 },
});
});
it("sends a zero cost per query when the admin types 0 instead of dropping it as unset", async () => {
const user = userEvent.setup();
renderPage();
await openEditModal(user);
fireEvent.change(screen.getByLabelText("Cost per Query (USD)"), { target: { value: "0" } });
await user.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(networking.updateSearchTool).toHaveBeenCalledTimes(1));
expect(vi.mocked(networking.updateSearchTool).mock.calls[0][2]).toMatchObject({
litellm_params: { input_cost_per_query: 0 },
});
});
it("sends null for the cost per query when the admin clears it, so the proxy falls back to the cost map", async () => {
const user = userEvent.setup();
renderPage();
await openEditModal(user);
fireEvent.change(screen.getByLabelText("Cost per Query (USD)"), { target: { value: "" } });
await user.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(networking.updateSearchTool).toHaveBeenCalledTimes(1));
expect(JSON.stringify(vi.mocked(networking.updateSearchTool).mock.calls[0][2])).toBe(
'{"search_tool_name":"Perplexity Search","litellm_params":{"search_provider":"perplexity","api_key":"sk-test-key","input_cost_per_query":null},"search_tool_info":{"description":"Test description"}}',
);
});
it("rejects a negative cost per query and does not call the update endpoint", async () => {
const user = userEvent.setup();
renderPage();
await openEditModal(user);
fireEvent.change(screen.getByLabelText("Cost per Query (USD)"), { target: { value: "-1" } });
await user.click(screen.getByRole("button", { name: "OK" }));
expect(await screen.findByText("Cost per query cannot be negative")).toBeInTheDocument();
expect(networking.updateSearchTool).not.toHaveBeenCalled();
});
it("carries an edited description through to search_tool_info", async () => {
const user = userEvent.setup();
renderPage();
@ -133,6 +190,7 @@ describe("SearchTools edit payload", () => {
litellm_params: {
search_provider: "perplexity",
api_key: "sk-test-key",
input_cost_per_query: 0.05,
},
search_tool_info: undefined,
});
@ -175,6 +233,7 @@ describe("SearchTools edit payload", () => {
litellm_params: {
search_provider: "perplexity",
api_key: null,
input_cost_per_query: undefined,
},
search_tool_info: undefined,
});

View file

@ -37,6 +37,7 @@ const editSearchToolShape = {
search_provider: z.string().min(1, "Please select a search provider"),
api_key: z.string().nullish(),
description: z.string().nullish(),
input_cost_per_query: z.number().nonnegative("Cost per query cannot be negative").nullish(),
};
const editSearchToolSchema = z.object(editSearchToolShape);
@ -94,6 +95,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
search_provider: tool.litellm_params.search_provider,
api_key: tool.litellm_params.api_key,
description: tool.search_tool_info?.description,
input_cost_per_query: tool.litellm_params.input_cost_per_query,
};
form.reset(editFormValues);
setSelectedToolId(toolId);
@ -204,6 +206,25 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
)}
</FormField>
<FormField
control={form.control}
name="input_cost_per_query"
label="Cost per Query (USD)"
description="Overrides the cost map price for this tool. Leave blank to use the provider's default price"
>
{({ ref, value, onChange, ...field }) => (
<Input
{...field}
ref={ref}
type="number"
step="any"
value={value ?? ""}
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
placeholder="e.g., 0.007"
/>
)}
</FormField>
<FormField control={form.control} name="description" label="Description">
{({ ref, value, ...field }) => (
<Textarea {...field} ref={ref} value={value ?? ""} rows={3} placeholder="Description of this search tool" />

View file

@ -20,11 +20,28 @@ describe("buildSearchToolPayload", () => {
litellm_params: {
search_provider: "perplexity",
api_key: undefined,
input_cost_per_query: undefined,
},
search_tool_info: undefined,
});
});
it("nests a custom cost per query under litellm_params so the proxy prices the tool with it", () => {
expect(JSON.stringify(buildSearchToolPayload({ ...minimal, input_cost_per_query: 0.05 }))).toBe(
'{"search_tool_name":"tool","litellm_params":{"search_provider":"perplexity","input_cost_per_query":0.05}}',
);
});
it("keeps a zero cost per query, since a free tool is a real price and not an unset one", () => {
expect(buildSearchToolPayload({ ...minimal, input_cost_per_query: 0 }).litellm_params.input_cost_per_query).toBe(0);
});
it("sends an explicit null when the cost per query is cleared, so an edit removes the old price", () => {
expect(JSON.stringify(buildSearchToolPayload({ ...minimal, input_cost_per_query: null }))).toBe(
'{"search_tool_name":"tool","litellm_params":{"search_provider":"perplexity","input_cost_per_query":null}}',
);
});
it("passes an api key through untouched", () => {
expect(buildSearchToolPayload({ ...minimal, api_key: "sk-secret" }).litellm_params.api_key).toBe("sk-secret");
});

View file

@ -5,6 +5,7 @@ export interface SearchToolFormValues {
search_provider: string;
api_key?: string | null;
description?: string | null;
input_cost_per_query?: number | null;
}
export interface SearchToolPayload {
@ -18,6 +19,7 @@ export const buildSearchToolPayload = (values: SearchToolFormValues): SearchTool
litellm_params: {
search_provider: values.search_provider,
api_key: values.api_key,
input_cost_per_query: values.input_cost_per_query,
},
search_tool_info: values.description ? { description: values.description } : undefined,
});