-
-
Notifications
You must be signed in to change notification settings - Fork 359
fix(usage-logs): filter blank model options #1287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -71,6 +71,7 @@ export function RequestFilters({ | |
| () => new Map(providers.map((provider) => [provider.id, provider.name])), | ||
| [providers] | ||
| ); | ||
| const modelOptions = useMemo(() => models.filter((model) => model.trim().length > 0), [models]); | ||
|
|
||
| useEffect(() => { | ||
| isMountedRef.current = true; | ||
|
|
@@ -256,7 +257,7 @@ export function RequestFilters({ | |
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value="all">{t("logs.filters.allModels")}</SelectItem> | ||
| {models.map((model) => ( | ||
| {modelOptions.map((model) => ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| <SelectItem key={model} value={model}> | ||
| {model} | ||
| </SelectItem> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1469,10 +1469,20 @@ export async function getUsedModels(): Promise<string[]> { | |||||||||||||||||||
| const results = await db | ||||||||||||||||||||
| .selectDistinct({ model: messageRequest.model }) | ||||||||||||||||||||
| .from(messageRequest) | ||||||||||||||||||||
| .where(and(isNull(messageRequest.deletedAt), sql`${messageRequest.model} IS NOT NULL`)) | ||||||||||||||||||||
| .where( | ||||||||||||||||||||
| and( | ||||||||||||||||||||
| isNull(messageRequest.deletedAt), | ||||||||||||||||||||
| sql`${messageRequest.model} IS NOT NULL`, | ||||||||||||||||||||
| sql`btrim(${messageRequest.model}) <> ''` | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
Comment on lines
+1473
to
+1477
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/repository/usage-logs.ts
Line: 1473-1477
Comment:
The `IS NOT NULL` condition is now redundant. In PostgreSQL, `btrim(NULL)` returns `NULL`, and `NULL <> ''` evaluates to `NULL` (not `TRUE`), so null rows are already excluded by the `btrim` condition. Keeping it is harmless, but removing it simplifies the predicate.
```suggestion
and(
isNull(messageRequest.deletedAt),
sql`btrim(${messageRequest.model}) <> ''`
)
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||||||||||||||||
| ) | ||||||||||||||||||||
| .orderBy(messageRequest.model); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return results.map((r) => r.model).filter((m): m is string => m !== null); | ||||||||||||||||||||
| return results.map((r) => r.model).filter(isNonBlankString); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function isNonBlankString(value: string | null): value is string { | ||||||||||||||||||||
| return typeof value === "string" && value.trim().length > 0; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| /** | ||
| * @vitest-environment happy-dom | ||
| */ | ||
|
|
||
| import type { ReactNode } from "react"; | ||
| import { act, useEffect } from "react"; | ||
| import { createRoot } from "react-dom/client"; | ||
| import { beforeEach, describe, expect, test, vi } from "vitest"; | ||
| import { useLazyModels } from "@/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options"; | ||
|
|
||
| const getModelListMock = vi.hoisted(() => vi.fn()); | ||
|
|
||
| vi.mock("@/lib/api-client/v1/actions/usage-logs", () => ({ | ||
| getEndpointList: vi.fn(async () => ({ ok: true, data: [] })), | ||
| getModelList: getModelListMock, | ||
| getStatusCodeList: vi.fn(async () => ({ ok: true, data: [] })), | ||
| })); | ||
|
|
||
| type HookSnapshot = ReturnType<typeof useLazyModels>; | ||
|
|
||
| function HookProbe({ onSnapshot }: { onSnapshot: (snapshot: HookSnapshot) => void }) { | ||
| const snapshot = useLazyModels(); | ||
|
|
||
| useEffect(() => { | ||
| onSnapshot(snapshot); | ||
| }, [snapshot, onSnapshot]); | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| function renderHookProbe(node: ReactNode) { | ||
| const container = document.createElement("div"); | ||
| document.body.appendChild(container); | ||
| const root = createRoot(container); | ||
|
|
||
| act(() => { | ||
| root.render(node); | ||
| }); | ||
|
|
||
| return () => { | ||
| act(() => root.unmount()); | ||
| container.remove(); | ||
| }; | ||
| } | ||
|
|
||
| async function waitForLoaded(read: () => HookSnapshot | null): Promise<HookSnapshot> { | ||
| const startedAt = Date.now(); | ||
|
|
||
| while (Date.now() - startedAt < 1000) { | ||
| const snapshot = read(); | ||
| if (snapshot?.isLoaded) return snapshot; | ||
|
|
||
| await act(async () => { | ||
| await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| }); | ||
| } | ||
|
|
||
| throw new Error("Timed out waiting for useLazyModels to load."); | ||
| } | ||
|
|
||
| describe("useLazyModels", () => { | ||
| beforeEach(() => { | ||
| vi.restoreAllMocks(); | ||
| getModelListMock.mockReset(); | ||
| }); | ||
|
|
||
| test("drops malformed model options instead of failing the lazy load", async () => { | ||
| getModelListMock.mockResolvedValue({ | ||
| ok: true, | ||
| data: ["", null, 42, " ", "claude-sonnet-4-5"] as unknown as string[], | ||
| }); | ||
|
|
||
| let latest: HookSnapshot | null = null; | ||
| const unmount = renderHookProbe( | ||
| <HookProbe | ||
| onSnapshot={(snapshot) => { | ||
| latest = snapshot; | ||
| }} | ||
| /> | ||
| ); | ||
|
|
||
| await act(async () => { | ||
| latest?.onOpenChange(true); | ||
| }); | ||
|
|
||
| const loaded = await waitForLoaded(() => latest); | ||
|
|
||
| expect(loaded.error).toBeNull(); | ||
| expect(loaded.data).toEqual(["claude-sonnet-4-5"]); | ||
|
|
||
| unmount(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |||||
| import type { ReactNode } from "react"; | ||||||
| import { act } from "react"; | ||||||
| import { createRoot } from "react-dom/client"; | ||||||
| import { describe, expect, test, vi } from "vitest"; | ||||||
| import { beforeEach, describe, expect, test, vi } from "vitest"; | ||||||
|
|
||||||
| vi.mock("server-only", () => ({})); | ||||||
|
|
||||||
|
|
@@ -39,6 +39,12 @@ const usersActionMocks = vi.hoisted(() => ({ | |||||
| })), | ||||||
| })); | ||||||
|
|
||||||
| const lazyFilterOptionMocks = vi.hoisted(() => ({ | ||||||
| models: [] as string[], | ||||||
| endpoints: [] as string[], | ||||||
| statusCodes: [] as number[], | ||||||
| })); | ||||||
|
|
||||||
| vi.mock("@/actions/usage-logs", () => ({ | ||||||
| exportUsageLogs: usageLogsActionMocks.exportUsageLogs, | ||||||
| getUsageLogSessionIdSuggestions: usageLogsActionMocks.getUsageLogSessionIdSuggestions, | ||||||
|
|
@@ -132,7 +138,11 @@ vi.mock("@/components/ui/select", () => ({ | |||||
| Select: ({ children }: { children?: ReactNode }) => <div>{children}</div>, | ||||||
| SelectTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>, | ||||||
| SelectContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>, | ||||||
| SelectItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>, | ||||||
| SelectItem: ({ children, value }: { children?: ReactNode; value: string }) => ( | ||||||
| <div data-select-item="" data-select-value={value}> | ||||||
| {children} | ||||||
| </div> | ||||||
| ), | ||||||
| SelectValue: () => <span />, | ||||||
| })); | ||||||
|
|
||||||
|
|
@@ -160,17 +170,17 @@ vi.mock("@/components/ui/command", () => ({ | |||||
| // Mock lazy filter hooks | ||||||
| vi.mock("@/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options", () => ({ | ||||||
| useLazyModels: () => ({ | ||||||
| data: [], | ||||||
| data: lazyFilterOptionMocks.models, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the filtering responsibility has been moved to the
Suggested change
|
||||||
| isLoading: false, | ||||||
| onOpenChange: vi.fn(), | ||||||
| }), | ||||||
| useLazyEndpoints: () => ({ | ||||||
| data: [], | ||||||
| data: lazyFilterOptionMocks.endpoints, | ||||||
| isLoading: false, | ||||||
| onOpenChange: vi.fn(), | ||||||
| }), | ||||||
| useLazyStatusCodes: () => ({ | ||||||
| data: [], | ||||||
| data: lazyFilterOptionMocks.statusCodes, | ||||||
| isLoading: false, | ||||||
| onOpenChange: vi.fn(), | ||||||
| }), | ||||||
|
|
@@ -197,7 +207,49 @@ function setReactInputValue(input: HTMLInputElement, value: string) { | |||||
| input.dispatchEvent(new Event("change", { bubbles: true })); | ||||||
| } | ||||||
|
|
||||||
| beforeEach(() => { | ||||||
| lazyFilterOptionMocks.models = []; | ||||||
| lazyFilterOptionMocks.endpoints = []; | ||||||
| lazyFilterOptionMocks.statusCodes = []; | ||||||
| }); | ||||||
|
|
||||||
| describe("UsageLogsFilters sessionId suggestions", () => { | ||||||
| test("should ignore empty model options before rendering Select items", async () => { | ||||||
| vi.clearAllMocks(); | ||||||
| document.body.innerHTML = ""; | ||||||
| lazyFilterOptionMocks.models = ["", " ", "claude-sonnet-4-5", "gpt-4o"]; | ||||||
|
|
||||||
| const container = document.createElement("div"); | ||||||
| document.body.appendChild(container); | ||||||
| const root = createRoot(container); | ||||||
|
|
||||||
| await act(async () => { | ||||||
| root.render( | ||||||
| <UsageLogsFilters | ||||||
| isAdmin={false} | ||||||
| providers={[]} | ||||||
| initialKeys={[]} | ||||||
| filters={{}} | ||||||
| onChange={() => {}} | ||||||
| onReset={() => {}} | ||||||
| /> | ||||||
| ); | ||||||
| }); | ||||||
|
|
||||||
| const values = Array.from(container.querySelectorAll("[data-select-value]")).map((el) => | ||||||
| el.getAttribute("data-select-value") | ||||||
| ); | ||||||
| expect(values).toContain("claude-sonnet-4-5"); | ||||||
| expect(values).toContain("gpt-4o"); | ||||||
| expect(values).not.toContain(""); | ||||||
| expect(values).not.toContain(" "); | ||||||
|
|
||||||
| await act(async () => { | ||||||
| root.unmount(); | ||||||
| }); | ||||||
| container.remove(); | ||||||
| }); | ||||||
|
|
||||||
| test("should debounce and require min length (>=2)", async () => { | ||||||
| vi.useFakeTimers(); | ||||||
| vi.clearAllMocks(); | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { describe, expect, test, vi } from "vitest"; | ||
|
|
||
| function createThenableQuery<T>(result: T) { | ||
| const query: any = Promise.resolve(result); | ||
| query.from = vi.fn(() => query); | ||
| query.where = vi.fn(() => query); | ||
| query.orderBy = vi.fn(() => query); | ||
| return query; | ||
| } | ||
|
|
||
| describe("usage log model filter options", () => { | ||
| test("getUsedModels omits empty or blank model names", async () => { | ||
| vi.resetModules(); | ||
|
|
||
| const selectDistinctMock = vi.fn(() => | ||
| createThenableQuery([ | ||
| { model: "" }, | ||
| { model: " " }, | ||
| { model: "claude-sonnet-4-5" }, | ||
| { model: "gpt-4o" }, | ||
| { model: null }, | ||
| ]) | ||
| ); | ||
|
|
||
| vi.doMock("@/drizzle/db", () => ({ | ||
|
Comment on lines
+14
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The mock's Prompt To Fix With AIThis is a comment left during a code review.
Path: tests/unit/repository/usage-logs-model-filter-options.test.ts
Line: 14-25
Comment:
**SQL WHERE conditions are not asserted in the test**
The mock's `where` function ignores its arguments and always returns the same pre-populated result set (including blank/null rows). This means the test only exercises the JavaScript post-processing filter in `getUsedModels`, not the `btrim` / `IS NOT NULL` SQL conditions. If those SQL conditions were accidentally removed, this test would still pass because `isNonBlankString` on line 1481 catches the blanks at the JS level. Consider adding an assertion that `where` received the expected SQL conditions, or document that SQL coverage is intentionally deferred to integration tests.
How can I resolve this? If you propose a fix, please make it concise. |
||
| db: { selectDistinct: selectDistinctMock }, | ||
| })); | ||
|
|
||
| const { getUsedModels } = await import("@/repository/usage-logs"); | ||
|
|
||
| await expect(getUsedModels()).resolves.toEqual(["claude-sonnet-4-5", "gpt-4o"]); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
modelsarray returned by theuseLazyModels()hook is already filtered to exclude empty and blank strings (both at the hook level and the repository/API level). Therefore, definingmodelOptionswith anotherfilterinsideuseMemois redundant. We can remove thisuseMemoentirely and usemodelsdirectly.