diff --git a/ui/litellm-dashboard/src/components/lens/investigations/investigationQuery.ts b/ui/litellm-dashboard/src/components/lens/investigations/investigationQuery.ts index 8f37a862baf..025fa73ee1a 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/investigationQuery.ts +++ b/ui/litellm-dashboard/src/components/lens/investigations/investigationQuery.ts @@ -1,7 +1,7 @@ import { Bot, CalendarClock, CircleDashed, SquareChevronRight } from "lucide-react"; import { type ClientIndex, filterItems } from "@/components/shared/search/evaluate"; -import type { FieldSpec, QueryLanguage } from "@/components/shared/search/language"; +import { ALL_OPERATORS, type FieldSpec, type QueryLanguage } from "@/components/shared/search/language"; import { scopeLabel } from "../model/format"; import type { Lens } from "../model/types"; @@ -14,7 +14,10 @@ const INVESTIGATION_FIELDS = { export type InvestigationField = keyof typeof INVESTIGATION_FIELDS; -export const INVESTIGATION_QUERY: QueryLanguage = { fields: INVESTIGATION_FIELDS }; +export const INVESTIGATION_QUERY: QueryLanguage = { + fields: INVESTIGATION_FIELDS, + ops: ALL_OPERATORS, +}; export const INVESTIGATION_INDEX: ClientIndex = { read: { diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runQuery.ts b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runQuery.ts index cd235b669e5..8cd66cde22a 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runQuery.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runQuery.ts @@ -4,7 +4,7 @@ import type { TraceSummary } from "../../types"; import { previewText, traceAgentNames } from "../../utils"; import { type ClientIndex, filterItems } from "@/components/shared/search/evaluate"; -import type { FieldSpec, QueryLanguage } from "@/components/shared/search/language"; +import { ALL_OPERATORS, type FieldSpec, type QueryLanguage } from "@/components/shared/search/language"; const RUN_FIELDS = { name: { group: "Run attributes", icon: SquareChevronRight, suggestValues: true }, @@ -17,7 +17,7 @@ const RUN_FIELDS = { export type RunField = keyof typeof RUN_FIELDS; -export const RUN_QUERY: QueryLanguage = { fields: RUN_FIELDS }; +export const RUN_QUERY: QueryLanguage = { fields: RUN_FIELDS, ops: ALL_OPERATORS }; /** Reads the run fields off a loaded page; free text searches trace id, input and name. */ export const RUN_INDEX: ClientIndex = { diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts index 73b65f05eae..3ca0068f1b2 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts @@ -2,7 +2,7 @@ import { getProxyBaseUrl } from "@/components/networking"; import type { TimeWindow } from "@/components/shared/timeline/Timeline"; import type { TraceQueryBody } from "../../types"; -import { valueMatcher } from "@/components/shared/search/language"; +import { isNegatedOp, valueMatcher } from "@/components/shared/search/language"; import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery"; import type { RunField } from "./runQuery"; @@ -48,7 +48,7 @@ const textPredicate = (term: string): string => { function filterPredicate(filter: SearchFilter): string { const predicate = FIELD_PREDICATES[filter.field](filter.value); - return filter.op === "neq" || filter.op === "nglob" ? `NOT (${predicate})` : predicate; + return isNegatedOp(filter.op) ? `NOT (${predicate})` : predicate; } const timeBound = (range: TimeWindow | undefined): string => diff --git a/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx b/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx index 97bc5f777fa..1e0e6090d75 100644 --- a/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx @@ -1,9 +1,18 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useEffect, useState } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes"; +import { + EXACT_NOTE_QUERY, + NEGATION_NOTE_QUERY, + NOTE_INDEX, + type NoteField, + NOTE_QUERY, + notes, + WILDCARD_NOTE_QUERY, +} from "./__fixtures__/notes"; +import type { QueryLanguage } from "./language"; import { SearchBox } from "./SearchBox"; import type { SearchQuery } from "./searchQuery"; import { itemValues, type ValueSource } from "./valueSource"; @@ -35,13 +44,15 @@ function NoteSearch({ value, onChange, values = itemValues(NOTE_INDEX, notes), + language = NOTE_QUERY, }: { value: string; onChange: (value: string) => void; values?: ValueSource; + language?: QueryLanguage; }) { return ( - + @@ -84,6 +95,26 @@ function LaggingHarness() { const box = () => screen.getByRole("combobox", { name: "Search notes" }); const undelivered = () => screen.getByRole("status", { name: "undelivered" }); const listbox = () => screen.getByRole("listbox", { name: "Search suggestions" }); +const operatorLabels = () => + within(listbox()) + .queryAllByText(/^(equals|not equals|wildcard match|does not contain)$/) + .map((label) => label.textContent); + +/** + * Timers only move when the test says so, so a slow machine cannot fire the debounce mid-typing. + * Testing Library flushes its own zero-delay timer through the `jest` global, which Vitest lacks. + */ +const pausedClockUser = () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + vi.stubGlobal("jest", { advanceTimersByTime: vi.advanceTimersByTime }); + return userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); +}; +const pauseTyping = () => act(() => vi.advanceTimersByTime(150)); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); describe("SearchBox", () => { it("builds a filter from the keyboard: field, then value", async () => { @@ -116,6 +147,33 @@ describe("SearchBox", () => { expect(box()).toHaveAttribute("aria-expanded", "false"); }); + it.each([ + ["negation and wildcards", NOTE_QUERY, ["equals", "not equals", "wildcard match", "does not contain"]], + ["negation only", NEGATION_NOTE_QUERY, ["equals", "not equals"]], + ["wildcards only", WILDCARD_NOTE_QUERY, ["equals", "wildcard match"]], + ])("explains exactly the operators a language with %s honors", async (_, language, expected) => { + const user = userEvent.setup(); + render(); + await user.click(box()); + await user.keyboard("tag:"); + expect(within(listbox()).getByText("Comparison operators")).toBeVisible(); + expect(operatorLabels()).toEqual(expected); + }); + + it("offers values without operator help or negated keys for an equality-only language", async () => { + const user = userEvent.setup(); + render(); + await user.click(box()); + await user.keyboard("tag:"); + expect(within(listbox()).getByRole("option", { name: "cron" })).toBeVisible(); + expect(within(listbox()).queryByText("Comparison operators")).not.toBeInTheDocument(); + expect(operatorLabels()).toEqual([]); + await user.keyboard("{Escape}{ArrowDown}"); + expect(listbox()).toBeVisible(); + await user.keyboard(" -ta"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + it("wraps arrow navigation and picks with Tab", async () => { const user = userEvent.setup(); render(); @@ -157,35 +215,77 @@ describe("SearchBox", () => { }); it("emits the query once typing pauses, not per keystroke", async () => { - const user = userEvent.setup(); + const user = pausedClockUser(); + const onChange = vi.fn(); + render(); + await user.click(box()); + await user.keyboard("ref"); + act(() => vi.advanceTimersByTime(149)); + await user.keyboard("und"); + act(() => vi.advanceTimersByTime(149)); + expect(onChange).not.toHaveBeenCalled(); + pauseTyping(); + expect(onChange).toHaveBeenCalledExactlyOnceWith("refund"); + }); + + it("keeps typing intact while the value it emitted arrives late", async () => { + const user = pausedClockUser(); + render(); + const deliver = () => user.click(screen.getByRole("button", { name: "Deliver next" })); + await user.click(box()); + await user.keyboard("ab"); + pauseTyping(); + expect(undelivered()).toHaveTextContent(/^ab$/, { normalizeWhitespace: false }); + await user.keyboard("c"); + await deliver(); + await user.click(box()); + await user.keyboard("d"); + pauseTyping(); + expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false }); + expect(undelivered()).toHaveTextContent(/^abc\|abcd$/, { normalizeWhitespace: false }); + await deliver(); + expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false }); + await deliver(); + expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false }); + expect(undelivered()).toBeEmptyDOMElement(); + }); + + it("hands over what was typed as soon as focus leaves, without waiting for the pause", async () => { + const user = pausedClockUser(); + render(); + await user.click(box()); + await user.keyboard("tag:cron"); + expect(undelivered()).toBeEmptyDOMElement(); + await user.click(document.body); + expect(undelivered()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false }); + pauseTyping(); + expect(undelivered()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false }); + }); + + it("delivers nothing more on blur once the query was already delivered", async () => { + const user = pausedClockUser(); const onChange = vi.fn(); render(); await user.click(box()); await user.keyboard("refund"); - await waitFor(() => expect(onChange).toHaveBeenCalledWith("refund")); + pauseTyping(); + expect(onChange).toHaveBeenCalledExactlyOnceWith("refund"); + await user.click(document.body); + await user.click(box()); + await user.click(document.body); + pauseTyping(); expect(onChange).toHaveBeenCalledOnce(); }); - it("keeps typing intact while the value it emitted arrives late", async () => { - const user = userEvent.setup(); - render(); + it("lets a query set from outside replace text that blur just handed over", async () => { + const user = pausedClockUser(); + render(); await user.click(box()); - await user.keyboard("ab"); - await waitFor(() => - expect(undelivered()).toHaveTextContent(/^ab$/, { - normalizeWhitespace: false, - }), - ); - await user.keyboard("c"); - await user.click(screen.getByRole("button", { name: "Deliver next" })); - await user.click(box()); - await user.keyboard("d"); - expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false }); - await waitFor(() => - expect(undelivered()).toHaveTextContent(/^abcd$/, { - normalizeWhitespace: false, - }), - ); + await user.keyboard("refund"); + await user.click(screen.getByRole("button", { name: "Load saved query" })); + expect(box()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false }); + pauseTyping(); + expect(box()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false }); }); it("shows a query set from outside, such as the URL", async () => { diff --git a/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx b/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx index 5697a1c1afc..32dde73e728 100644 --- a/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx +++ b/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx @@ -2,7 +2,7 @@ import "prosemirror-view/style/prosemirror.css"; -import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { useDebouncer } from "@tanstack/react-pacer/debouncer"; import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror"; import { Check, Copy, CornerDownLeft, type LucideIcon, Search } from "lucide-react"; import { Schema } from "prosemirror-model"; @@ -13,7 +13,7 @@ import { type ComponentProps, createContext, type ReactNode, useContext, useId, import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cva.config"; -import { parseQuery, type QueryClause, type QueryLanguage } from "./language"; +import { type FilterOp, languageOps, parseQuery, type QueryClause, type QueryLanguage } from "./language"; import { type SearchQuery, toSearchQuery } from "./searchQuery"; import { completingField, completingPrefix, suggest, type Suggestion, type SuggestionMenu } from "./suggestions"; import { NO_VALUES, type ValueSource } from "./valueSource"; @@ -23,11 +23,11 @@ const schema = new Schema({ nodes: { doc: { content: "text*" }, text: {} } }); const EMIT_WAIT_MS = 150; const KEEP_MENU_CLOSED = "searchBoxKeepMenuClosed"; -const OPERATORS = [ - { label: "equals", example: "foo:bar" }, - { label: "not equals", example: "-foo:bar" }, - { label: "wildcard match", example: "foo:*bar*" }, - { label: "does not contain", example: "-foo:*bar*" }, +const OPERATORS: readonly { readonly op: FilterOp; readonly label: string; readonly example: string }[] = [ + { op: "eq", label: "equals", example: "foo:bar" }, + { op: "neq", label: "not equals", example: "-foo:bar" }, + { op: "glob", label: "wildcard match", example: "foo:*bar*" }, + { op: "nglob", label: "does not contain", example: "-foo:*bar*" }, ]; /** Colours each `key:` so the filters stand out from free text. */ @@ -75,6 +75,8 @@ interface SearchBoxState { readonly menu: SuggestionMenu | null; readonly activeId: string | undefined; readonly icon: (field: string) => LucideIcon; + /** The ops the language can express, so the help only lists forms the surface honors. */ + readonly ops: readonly FilterOp[]; } const SearchBoxContext = createContext(null); @@ -146,11 +148,12 @@ function Root({ menu: shownMenu, activeId: activeItem?.id, icon: (field) => language.fields[field as F].icon, + ops: languageOps(language), }), [listId, text, clauses, shownMenu, activeItem, language], ); - const emit = useDebouncedCallback( + const emitter = useDebouncer( (next: string) => { setUnechoed((current) => [...current, next]); onValueChange(next); @@ -163,7 +166,7 @@ function Root({ if (tr.selectionSet || tr.docChanged) setActive(0); if (!tr.docChanged) return; setMenuOpen(!tr.getMeta(KEEP_MENU_CLOSED)); - emit(tr.doc.textContent); + emitter.maybeExecute(tr.doc.textContent); }; const handleKeyDown = (view: EditorView, event: KeyboardEvent): boolean => { @@ -200,6 +203,7 @@ function Root({ }, blur: () => { setFocused(false); + emitter.flush(); return false; }, }} @@ -262,7 +266,7 @@ export type SearchBoxSuggestionsProps = ComponentProps<"div">; * `children` render in the footer beside the key hints, e.g. a `CopyCommand`. */ function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProps) { - const { listId, menu, activeId, icon } = useSearchBox(); + const { listId, menu, activeId, icon, ops } = useSearchBox(); const pick = useEditorEventCallback((view, item: Suggestion) => applySuggestion(view, item)); if (!menu) return null; return ( @@ -302,7 +306,7 @@ function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProp Loading values… )} - {menu.showOperators && } + {menu.showOperators && }
@@ -364,11 +368,11 @@ function CopyCommand({ title, command }: SearchBoxCopyCommandP ); } -function OperatorHints() { +function OperatorHints({ ops }: { ops: readonly FilterOp[] }) { return (
Comparison operators
- {OPERATORS.map((op) => ( + {OPERATORS.filter((op) => ops.includes(op.op)).map((op) => (
{op.label} {op.example} diff --git a/ui/litellm-dashboard/src/components/shared/search/__fixtures__/notes.ts b/ui/litellm-dashboard/src/components/shared/search/__fixtures__/notes.ts index a745233e764..99baa23e7a5 100644 --- a/ui/litellm-dashboard/src/components/shared/search/__fixtures__/notes.ts +++ b/ui/litellm-dashboard/src/components/shared/search/__fixtures__/notes.ts @@ -1,7 +1,7 @@ import { Braces, Hash, Tag, Type } from "lucide-react"; import type { ClientIndex } from "../evaluate"; -import type { FieldSpec, QueryLanguage } from "../language"; +import { ALL_OPERATORS, EQUALITY_ONLY, type FieldSpec, type QueryLanguage } from "../language"; export interface Note { readonly id: string; @@ -19,7 +19,17 @@ const NOTE_FIELDS = { export type NoteField = keyof typeof NOTE_FIELDS; -export const NOTE_QUERY: QueryLanguage = { fields: NOTE_FIELDS }; +export const NOTE_QUERY: QueryLanguage = { fields: NOTE_FIELDS, ops: ALL_OPERATORS }; +/** The same vocabulary over a backend that only filters by equality. */ +export const EXACT_NOTE_QUERY: QueryLanguage = { fields: NOTE_FIELDS, ops: EQUALITY_ONLY }; +export const NEGATION_NOTE_QUERY: QueryLanguage = { + fields: NOTE_FIELDS, + ops: { negation: true, wildcard: false }, +}; +export const WILDCARD_NOTE_QUERY: QueryLanguage = { + fields: NOTE_FIELDS, + ops: { negation: false, wildcard: true }, +}; export const NOTE_INDEX: ClientIndex = { read: { diff --git a/ui/litellm-dashboard/src/components/shared/search/evaluate.test.ts b/ui/litellm-dashboard/src/components/shared/search/evaluate.test.ts index c67824514a9..e8d29938e96 100644 --- a/ui/litellm-dashboard/src/components/shared/search/evaluate.test.ts +++ b/ui/litellm-dashboard/src/components/shared/search/evaluate.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { note, NOTE_INDEX, NOTE_QUERY, notes } from "./__fixtures__/notes"; +import { + EXACT_NOTE_QUERY, + NEGATION_NOTE_QUERY, + note, + NOTE_INDEX, + NOTE_QUERY, + notes, + WILDCARD_NOTE_QUERY, +} from "./__fixtures__/notes"; import { evaluate, fieldValues, filterItems } from "./evaluate"; const ids = (text: string) => filterItems(NOTE_QUERY, NOTE_INDEX, notes, text).map((n) => n.id); @@ -20,6 +28,28 @@ describe("evaluate", () => { expect(by("nglob", "*search*")).toEqual(["aaa111", "ccc333"]); }); + it("treats a star as a literal under eq and neq, so an equality-only query never globs", () => { + const starred = [note({ id: "star", title: "a*b" }), note({ id: "x", title: "axb" })]; + const by = (op: "eq" | "neq") => + evaluate(NOTE_INDEX, starred, { text: [], filters: [{ field: "title", op, value: "a*b" }] }).map((n) => n.id); + expect(by("eq")).toEqual(["star"]); + expect(by("neq")).toEqual(["x"]); + expect(filterItems(EXACT_NOTE_QUERY, NOTE_INDEX, starred, "title:a*b").map((n) => n.id)).toEqual(["star"]); + }); + + it("follows the language's capabilities from the typed text to the matched items", () => { + const tagged = [...notes, note({ id: "star", tags: ["research*"] })]; + const hits = (language: typeof NOTE_QUERY, text: string) => + filterItems(language, NOTE_INDEX, tagged, text).map((n) => n.id); + expect(hits(NOTE_QUERY, "-tag:research*")).toEqual(["aaa111", "ccc333"]); + expect(hits(EXACT_NOTE_QUERY, "tag:research*")).toEqual(["star"]); + expect(hits(EXACT_NOTE_QUERY, "-tag:researcher")).toEqual([]); + expect(hits(NEGATION_NOTE_QUERY, "-tag:researcher")).toEqual(["aaa111", "ccc333", "star"]); + expect(hits(NEGATION_NOTE_QUERY, "-tag:research*")).toEqual(["aaa111", "bbb222", "ccc333"]); + expect(hits(WILDCARD_NOTE_QUERY, "tag:research*")).toEqual(["bbb222", "star"]); + expect(hits(WILDCARD_NOTE_QUERY, "-tag:researcher")).toEqual([]); + }); + it("requires every term and every filter", () => { expect( evaluate(NOTE_INDEX, notes, { text: ["refund"], filters: [{ field: "tag", op: "eq", value: "billing" }] }), diff --git a/ui/litellm-dashboard/src/components/shared/search/evaluate.ts b/ui/litellm-dashboard/src/components/shared/search/evaluate.ts index 6c14b9979cd..97385856ccb 100644 --- a/ui/litellm-dashboard/src/components/shared/search/evaluate.ts +++ b/ui/litellm-dashboard/src/components/shared/search/evaluate.ts @@ -1,4 +1,4 @@ -import { parseQuery, type QueryLanguage, valueMatcher } from "./language"; +import { exactMatcher, isGlobOp, isNegatedOp, parseQuery, type QueryLanguage, valueMatcher } from "./language"; import { type SearchFilter, type SearchQuery, toSearchQuery } from "./searchQuery"; /** How to read a language's fields off items already in memory. */ @@ -9,8 +9,9 @@ export interface ClientIndex { } function matchesFilter(index: ClientIndex, item: T, filter: SearchFilter): boolean { - const matches = index.read[filter.field](item).some(valueMatcher(filter.value)); - return filter.op === "neq" || filter.op === "nglob" ? !matches : matches; + const matcher = isGlobOp(filter.op) ? valueMatcher(filter.value) : exactMatcher(filter.value); + const matches = index.read[filter.field](item).some(matcher); + return isNegatedOp(filter.op) ? !matches : matches; } function matchesText(index: ClientIndex, item: T, term: string): boolean { diff --git a/ui/litellm-dashboard/src/components/shared/search/language.test.ts b/ui/litellm-dashboard/src/components/shared/search/language.test.ts index 60d3e459188..af864ffeb99 100644 --- a/ui/litellm-dashboard/src/components/shared/search/language.test.ts +++ b/ui/litellm-dashboard/src/components/shared/search/language.test.ts @@ -1,17 +1,67 @@ import { describe, expect, it } from "vitest"; -import { NOTE_QUERY } from "./__fixtures__/notes"; -import { parseQuery, valueMatcher } from "./language"; +import { EXACT_NOTE_QUERY, NEGATION_NOTE_QUERY, NOTE_QUERY, WILDCARD_NOTE_QUERY } from "./__fixtures__/notes"; +import { exactMatcher, languageOps, parseQuery, valueMatcher } from "./language"; describe("parseQuery", () => { it("splits clauses on whitespace, keeps quoted stretches, and only promotes known keys to fields", () => { expect(parseQuery(NOTE_QUERY, 'refund -Tag:"a b" foo:bar tag:')).toEqual([ { kind: "text", value: "refund", from: 0, to: 6 }, - { kind: "field", field: "tag", negated: true, keyTo: 11, value: "a b", from: 7, to: 17 }, + { kind: "field", field: "tag", op: "neq", keyTo: 11, value: "a b", from: 7, to: 17 }, { kind: "text", value: "foo:bar", from: 18, to: 25 }, - { kind: "field", field: "tag", negated: false, keyTo: 29, value: "", from: 26, to: 30 }, + { kind: "field", field: "tag", op: "eq", keyTo: 29, value: "", from: 26, to: 30 }, ]); }); + + it("picks the op from the dash and the wildcard when the language honors them", () => { + const ops = (text: string) => parseQuery(NOTE_QUERY, text).map((c) => (c.kind === "field" ? c.op : c.kind)); + expect(ops("tag:a -tag:b tag:*c -tag:d*")).toEqual(["eq", "neq", "glob", "nglob"]); + }); + + it("keeps a dash as text and a star as a literal for an equality-only language", () => { + expect(parseQuery(EXACT_NOTE_QUERY, "-tag:a tag:b*")).toEqual([ + { kind: "text", value: "-tag:a", from: 0, to: 6 }, + { kind: "field", field: "tag", op: "eq", keyTo: 10, value: "b*", from: 7, to: 13 }, + ]); + }); + + it.each([ + [NOTE_QUERY, ["nglob", "neq", "glob", "eq"]], + [EXACT_NOTE_QUERY, ["text", "text", "eq", "eq"]], + [NEGATION_NOTE_QUERY, ["neq", "neq", "eq", "eq"]], + [WILDCARD_NOTE_QUERY, ["text", "text", "glob", "eq"]], + ])("reads each operator form the way the language's capabilities allow (%#)", (language, expected) => { + const ops = parseQuery(language, "-tag:research* -tag:research tag:research* tag:research").map((c) => + c.kind === "field" ? c.op : c.kind, + ); + expect(ops).toEqual(expected); + }); + + it("keeps the star in the value and the dash in the text whatever the capabilities", () => { + expect(parseQuery(NEGATION_NOTE_QUERY, "-Tag:research*")).toEqual([ + { kind: "field", field: "tag", op: "neq", keyTo: 4, value: "research*", from: 0, to: 14 }, + ]); + expect(parseQuery(WILDCARD_NOTE_QUERY, '-tag:"a b" tag:"a *"')).toEqual([ + { kind: "text", value: '-tag:"a b"', from: 0, to: 10 }, + { kind: "field", field: "tag", op: "glob", keyTo: 14, value: "a *", from: 11, to: 20 }, + ]); + }); +}); + +describe("languageOps", () => { + it("lists only the ops a language can express", () => { + expect(languageOps(NOTE_QUERY)).toEqual(["eq", "neq", "glob", "nglob"]); + expect(languageOps(EXACT_NOTE_QUERY)).toEqual(["eq"]); + expect(languageOps({ ...NOTE_QUERY, ops: { negation: true, wildcard: false } })).toEqual(["eq", "neq"]); + expect(languageOps({ ...NOTE_QUERY, ops: { negation: false, wildcard: true } })).toEqual(["eq", "glob"]); + }); +}); + +describe("exactMatcher", () => { + it("compares the whole value ignoring case and treats a star as a character", () => { + expect(exactMatcher("A*b")("a*B")).toBe(true); + expect(exactMatcher("a*b")("axb")).toBe(false); + }); }); describe("valueMatcher", () => { diff --git a/ui/litellm-dashboard/src/components/shared/search/language.ts b/ui/litellm-dashboard/src/components/shared/search/language.ts index 9200d96a10a..0c45d29d2b0 100644 --- a/ui/litellm-dashboard/src/components/shared/search/language.ts +++ b/ui/litellm-dashboard/src/components/shared/search/language.ts @@ -7,11 +7,35 @@ export interface FieldSpec { readonly suggestValues: boolean; } +export type FilterOp = "eq" | "neq" | "glob" | "nglob"; + +/** Which `key:value` forms beyond equality the surface behind the box can honor. */ +export interface QueryOperators { + /** `-key:value` excludes; otherwise the dash is ordinary text. */ + readonly negation: boolean; + /** `*` inside a value is a wildcard; otherwise it is a literal character. */ + readonly wildcard: boolean; +} + +export const ALL_OPERATORS: QueryOperators = { negation: true, wildcard: true }; +export const EQUALITY_ONLY: QueryOperators = { negation: false, wildcard: false }; + /** The `key:value` vocabulary of one search box. Where the data lives is the evaluator's concern, not the language's. */ export interface QueryLanguage { readonly fields: Readonly>; + readonly ops: QueryOperators; } +export const languageOps = ({ ops }: QueryLanguage): readonly FilterOp[] => [ + "eq", + ...(ops.negation ? (["neq"] as const) : []), + ...(ops.wildcard ? (["glob"] as const) : []), + ...(ops.negation && ops.wildcard ? (["nglob"] as const) : []), +]; + +export const isNegatedOp = (op: FilterOp): boolean => op === "neq" || op === "nglob"; +export const isGlobOp = (op: FilterOp): boolean => op === "glob" || op === "nglob"; + interface Span { readonly from: number; readonly to: number; @@ -22,7 +46,7 @@ export type QueryClause = | (Span & { readonly kind: "field"; readonly field: F; - readonly negated: boolean; + readonly op: FilterOp; readonly keyTo: number; readonly value: string; }); @@ -43,19 +67,29 @@ function tokenize(text: string): (Span & { raw: string })[] { })); } +const filterOp = (negated: boolean, glob: boolean): FilterOp => { + if (glob) return negated ? "nglob" : "glob"; + return negated ? "neq" : "eq"; +}; + function parseToken( language: QueryLanguage, { raw, from, to }: Span & { raw: string }, ): QueryClause { const match = /^(-?)([A-Za-z_]+):(.*)$/.exec(raw); - const key = match?.[2].toLowerCase() ?? ""; - if (!match || !Object.hasOwn(language.fields, key)) return { kind: "text", value: unquote(raw), from, to }; + const text: QueryClause = { kind: "text", value: unquote(raw), from, to }; + if (!match) return text; + const key = match[2].toLowerCase(); + const negated = match[1] === "-"; + if (!Object.hasOwn(language.fields, key)) return text; + if (negated && !language.ops.negation) return text; + const value = unquote(match[3]); return { kind: "field", field: key as F, - negated: match[1] === "-", + op: filterOp(negated, language.ops.wildcard && value.includes("*")), keyTo: from + match[1].length + match[2].length, - value: unquote(match[3]), + value, from, to, }; @@ -64,10 +98,16 @@ function parseToken( export const parseQuery = (language: QueryLanguage, text: string): QueryClause[] => tokenize(text).map((token) => parseToken(language, token)); +/** Whole-value match, ignoring case; `*` is a literal. */ +export function exactMatcher(pattern: string): (value: string) => boolean { + const needle = pattern.toLowerCase(); + return (value) => value.toLowerCase() === needle; +} + /** `bar` matches the whole value, `*bar*` is a glob; both ignore case. */ export function valueMatcher(pattern: string): (value: string) => boolean { const needle = pattern.toLowerCase(); - if (!needle.includes("*")) return (value) => value.toLowerCase() === needle; + if (!needle.includes("*")) return exactMatcher(needle); const segments = needle.split("*"); const first = segments[0]; const last = segments.at(-1); diff --git a/ui/litellm-dashboard/src/components/shared/search/searchQuery.test.ts b/ui/litellm-dashboard/src/components/shared/search/searchQuery.test.ts index 2a37adede45..de8c0c770ff 100644 --- a/ui/litellm-dashboard/src/components/shared/search/searchQuery.test.ts +++ b/ui/litellm-dashboard/src/components/shared/search/searchQuery.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { NOTE_QUERY } from "./__fixtures__/notes"; +import { EXACT_NOTE_QUERY, NOTE_QUERY } from "./__fixtures__/notes"; import { parseQuery } from "./language"; import { toSearchQuery } from "./searchQuery"; @@ -24,6 +24,13 @@ describe("toSearchQuery", () => { expect(query("refund tag:")).toEqual({ text: ["refund"], filters: [] }); }); + it("only ever emits eq for an equality-only language", () => { + expect(toSearchQuery(parseQuery(EXACT_NOTE_QUERY, "-tag:cron tag:*bill*"))).toEqual({ + text: ["-tag:cron"], + filters: [{ field: "tag", op: "eq", value: "*bill*" }], + }); + }); + it("keeps an unknown key as free text", () => { expect(query("foo:bar")).toEqual({ text: ["foo:bar"], filters: [] }); }); diff --git a/ui/litellm-dashboard/src/components/shared/search/searchQuery.ts b/ui/litellm-dashboard/src/components/shared/search/searchQuery.ts index 397901dc21f..f49b07e5102 100644 --- a/ui/litellm-dashboard/src/components/shared/search/searchQuery.ts +++ b/ui/litellm-dashboard/src/components/shared/search/searchQuery.ts @@ -1,6 +1,4 @@ -import type { QueryClause } from "./language"; - -export type FilterOp = "eq" | "neq" | "glob" | "nglob"; +import type { FilterOp, QueryClause } from "./language"; export interface SearchFilter { readonly field: F; @@ -18,18 +16,11 @@ export interface SearchQuery { readonly filters: readonly SearchFilter[]; } -const filterOp = (negated: boolean, value: string): FilterOp => { - if (value.includes("*")) return negated ? "nglob" : "glob"; - return negated ? "neq" : "eq"; -}; - /** A key typed without a value yet narrows nothing, so the list does not blank out mid-typing. */ export function toSearchQuery(clauses: readonly QueryClause[]): SearchQuery { const text = clauses.flatMap((clause) => (clause.kind === "text" && clause.value ? [clause.value] : [])); const filters = clauses.flatMap((clause) => - clause.kind === "field" && clause.value - ? [{ field: clause.field, op: filterOp(clause.negated, clause.value), value: clause.value }] - : [], + clause.kind === "field" && clause.value ? [{ field: clause.field, op: clause.op, value: clause.value }] : [], ); return { text, filters }; } diff --git a/ui/litellm-dashboard/src/components/shared/search/suggestions.test.ts b/ui/litellm-dashboard/src/components/shared/search/suggestions.test.ts index 1e1dedd30bf..00d5ef4e405 100644 --- a/ui/litellm-dashboard/src/components/shared/search/suggestions.test.ts +++ b/ui/litellm-dashboard/src/components/shared/search/suggestions.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; -import { type Note, note, NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes"; +import { + EXACT_NOTE_QUERY, + NEGATION_NOTE_QUERY, + type Note, + note, + NOTE_INDEX, + type NoteField, + NOTE_QUERY, + notes, + WILDCARD_NOTE_QUERY, +} from "./__fixtures__/notes"; import { fieldValues } from "./evaluate"; import { completingField, completingPrefix, suggest, type Suggestion, type SuggestionMenu } from "./suggestions"; import type { FieldValues } from "./valueSource"; @@ -86,6 +96,35 @@ describe("suggest", () => { expect(atEnd("body:x")).toBeNull(); }); + it("skips the operator help and the negated key for an equality-only language", () => { + const exact = (text: string) => suggest(EXACT_NOTE_QUERY, text, text.length, lookupIn(notes)); + expect(exact("tag:")?.showOperators).toBe(false); + expect(labels(exact("tag:"))).toEqual(["billing", "cron", "researcher", "triage"]); + expect(exact("-ta")).toBeNull(); + expect(completingPrefix(EXACT_NOTE_QUERY, "tag:a*", 6)).toBe("a*"); + expect(completingPrefix(NOTE_QUERY, "tag:a*", 6)).toBe("a"); + expect(labels(exact("tag:*"))).toEqual([]); + }); + + it("keeps a literal star in the value prefix unless the language has wildcards", () => { + const starred = [...notes, note({ tags: ["a*b", "ab"] })]; + const menu = (language: typeof NOTE_QUERY) => suggest(language, "tag:a*", 6, lookupIn(starred)); + expect(labels(menu(NEGATION_NOTE_QUERY))).toEqual(["a*b"]); + expect(labels(menu(EXACT_NOTE_QUERY))).toEqual(["a*b"]); + expect(labels(menu(WILDCARD_NOTE_QUERY))).toEqual(["a*b", "ab", "researcher", "triage"]); + expect(completingPrefix(NEGATION_NOTE_QUERY, "tag:a*", 6)).toBe("a*"); + expect(completingPrefix(WILDCARD_NOTE_QUERY, "tag:a*", 6)).toBe("a"); + }); + + it("offers a negated key only when the language can negate", () => { + const fields = (language: typeof NOTE_QUERY) => suggest(language, "-ta", 3, lookupIn(notes)); + expect(apply("-ta", items(fields(NEGATION_NOTE_QUERY))[0])).toBe("-tag:"); + expect(fields(WILDCARD_NOTE_QUERY)).toBeNull(); + expect(suggest(WILDCARD_NOTE_QUERY, "-tag:", 5, lookupIn(notes))).toBeNull(); + expect(suggest(NEGATION_NOTE_QUERY, "tag:", 4, lookupIn(notes))?.showOperators).toBe(true); + expect(suggest(WILDCARD_NOTE_QUERY, "tag:", 4, lookupIn(notes))?.showOperators).toBe(true); + }); + it("offers fields in the gap between tokens, but nothing mid-token", () => { const text = "refund tag:cron"; expect(apply(text, items(at(text, 7))[1])).toBe("refund tag: tag:cron"); diff --git a/ui/litellm-dashboard/src/components/shared/search/suggestions.ts b/ui/litellm-dashboard/src/components/shared/search/suggestions.ts index fd06dae96fc..131e6bd1b19 100644 --- a/ui/litellm-dashboard/src/components/shared/search/suggestions.ts +++ b/ui/litellm-dashboard/src/components/shared/search/suggestions.ts @@ -1,4 +1,4 @@ -import { type FieldClause, languageFields, parseQuery, type QueryLanguage } from "./language"; +import { type FieldClause, languageFields, languageOps, parseQuery, type QueryLanguage } from "./language"; import type { FieldValues } from "./valueSource"; export interface Suggestion { @@ -47,7 +47,7 @@ function target(language: QueryLanguage, text: string, curs } if (clause.kind === "field") return { kind: "value", clause }; const raw = text.slice(clause.from, clause.to); - const negated = raw.startsWith("-"); + const negated = language.ops.negation && raw.startsWith("-"); const prefix = negated ? raw.slice(1) : raw; if (/[:"]/.test(prefix)) return null; return { kind: "field", prefix, negated, from: clause.from, to: clause.to }; @@ -59,10 +59,13 @@ export function completingField(language: QueryLanguage, te return found?.kind === "value" && language.fields[found.clause.field].suggestValues ? found.clause.field : null; } +const literalPart = (language: QueryLanguage, value: string): string => + language.ops.wildcard ? value.replaceAll("*", "") : value; + /** The substring typed so far for the value at the cursor, wildcards stripped. */ export function completingPrefix(language: QueryLanguage, text: string, cursor: number): string { const found = target(language, text, cursor); - return found?.kind === "value" ? found.clause.value.replaceAll("*", "") : ""; + return found?.kind === "value" ? literalPart(language, found.clause.value) : ""; } function fieldMenu( @@ -89,12 +92,13 @@ function fieldMenu( } function valueMenu( + language: QueryLanguage, clause: FieldClause, text: string, { values, loading }: FieldValues, ): SuggestionMenu | null { const from = clause.keyTo + 1; - const needle = clause.value.replaceAll("*", "").toLowerCase(); + const needle = literalPart(language, clause.value).toLowerCase(); const trailing = clause.to === text.length ? " " : ""; const suggestions = values .filter((value) => value.toLowerCase().includes(needle)) @@ -108,7 +112,7 @@ function valueMenu( insert: quoteIfNeeded(value) + trailing, completesClause: true, })); - const showOperators = clause.value === ""; + const showOperators = clause.value === "" && languageOps(language).length > 1; if (suggestions.length === 0 && !showOperators && !loading) return null; return { groups: suggestions.length ? [{ heading: clause.field, items: suggestions }] : [], showOperators, loading }; } @@ -125,6 +129,7 @@ export function suggest( if (found.kind === "field") return fieldMenu(language, found); const { field } = found.clause; return valueMenu( + language, found.clause, text, language.fields[field].suggestValues ? lookup(field) : { values: [], loading: false },