mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
67fab6737b
commit
16abb004a5
8 changed files with 193 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue