From 82084499086289be4a879f6060b7c8ced5ac29de Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 16:26:43 -0700 Subject: [PATCH] refactor(ui): declare the operators a search language honors The box always advertised negation and wildcards and the parser always emitted them, so a surface whose API only filters by equality would promise forms it cannot honor. Each QueryLanguage now declares which operators it supports; the parser, evaluator, suggestions and operator help follow that declaration, and a star is a literal under eq. Co-Authored-By: Claude Fable 5.1 --- .../lens/investigations/investigationQuery.ts | 7 ++- .../lens/traces/list/runSearch/runQuery.ts | 4 +- .../lens/traces/list/runSearch/runSql.ts | 4 +- .../shared/search/SearchBox.test.tsx | 17 +++++- .../components/shared/search/SearchBox.tsx | 23 ++++---- .../shared/search/__fixtures__/notes.ts | 6 ++- .../components/shared/search/evaluate.test.ts | 11 +++- .../src/components/shared/search/evaluate.ts | 7 +-- .../components/shared/search/language.test.ts | 36 +++++++++++-- .../src/components/shared/search/language.ts | 52 ++++++++++++++++--- .../shared/search/searchQuery.test.ts | 9 +++- .../components/shared/search/searchQuery.ts | 13 +---- .../shared/search/suggestions.test.ts | 12 ++++- .../components/shared/search/suggestions.ts | 15 ++++-- 14 files changed, 165 insertions(+), 51 deletions(-) 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 3a69345797c..8d0a074e27d 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 9a3751593bd..a82ffcad94a 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 @@ -1,7 +1,7 @@ import { getProxyBaseUrl } from "@/components/networking"; import type { TimeWindow } from "../TracesTimeline"; -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"; @@ -47,7 +47,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..a4ca82ea93d 100644 --- a/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/search/SearchBox.test.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { NOTE_INDEX, type NoteField, NOTE_QUERY, notes } 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 +36,15 @@ function NoteSearch({ value, onChange, values = itemValues(NOTE_INDEX, notes), + language = NOTE_QUERY, }: { value: string; onChange: (value: string) => void; values?: ValueSource; + language?: QueryLanguage; }) { return ( - + @@ -116,6 +119,18 @@ describe("SearchBox", () => { expect(box()).toHaveAttribute("aria-expanded", "false"); }); + it("explains only the operators the language honors", async () => { + const user = userEvent.setup(); + const negationOnly = { ...NOTE_QUERY, ops: { negation: true, wildcard: false } }; + render(); + await user.click(box()); + await user.keyboard("tag:"); + expect(within(listbox()).getByText("Comparison operators")).toBeVisible(); + expect(within(listbox()).getByText("not equals")).toBeVisible(); + expect(within(listbox()).queryByText("wildcard match")).not.toBeInTheDocument(); + expect(within(listbox()).queryByText("does not contain")).not.toBeInTheDocument(); + }); + it("wraps arrow navigation and picks with Tab", async () => { const user = userEvent.setup(); render(); diff --git a/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx b/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx index ad9b5268b60..2d5347e083c 100644 --- a/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx +++ b/ui/litellm-dashboard/src/components/shared/search/SearchBox.tsx @@ -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,6 +148,7 @@ function Root({ menu: shownMenu, activeId: activeItem?.id, icon: (field) => language.fields[field as F].icon, + ops: languageOps(language), }), [listId, text, clauses, shownMenu, activeItem, language], ); @@ -262,7 +265,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 +305,7 @@ function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProp Loading values… )} - {menu.showOperators && } + {menu.showOperators && }
@@ -364,11 +367,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..6022f2bb1ce 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,9 @@ 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 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..b532f3135ab 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,6 @@ import { describe, expect, it } from "vitest"; -import { note, NOTE_INDEX, NOTE_QUERY, notes } from "./__fixtures__/notes"; +import { EXACT_NOTE_QUERY, note, NOTE_INDEX, NOTE_QUERY, notes } 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 +20,15 @@ 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("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..4d7f86f0db8 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,45 @@ import { describe, expect, it } from "vitest"; -import { NOTE_QUERY } from "./__fixtures__/notes"; -import { parseQuery, valueMatcher } from "./language"; +import { EXACT_NOTE_QUERY, 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 }, + ]); + }); +}); + +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..402d5354a1f 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,6 @@ import { describe, expect, it } from "vitest"; -import { type Note, note, NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes"; +import { EXACT_NOTE_QUERY, type Note, note, NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes"; import { fieldValues } from "./evaluate"; import { completingField, completingPrefix, suggest, type Suggestion, type SuggestionMenu } from "./suggestions"; import type { FieldValues } from "./valueSource"; @@ -86,6 +86,16 @@ 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("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 },