Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions framework/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[fix]: rebuild token usage from denormalized columns in hybrid log list [@G-XD](https://github.com/G-XD)
48 changes: 48 additions & 0 deletions framework/logstore/hybrid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,54 @@ func TestHybrid_ResponsesInputHistoryPreservesLastUserMessage(t *testing.T) {
require.Len(t, found.ResponsesInputHistoryParsed, 2, "full responses history should be hydrated from S3")
}

func TestHybrid_TokenUsageSummaryForListPreview(t *testing.T) {
// token_usage is offloaded to object storage and cleared from the DB row. The
// denormalized prompt/completion/total columns must remain so list queries can
// rebuild token_usage for the UI without hydrating from S3 (same pattern as
// content_summary for message previews).
hybrid, inner, objStore := newTestHybrid(t)
defer hybrid.Close(context.Background())
ctx := context.Background()

entry := &Log{
ID: "chat-tokens-1",
Timestamp: time.Now().UTC(),
Provider: "openai",
Model: "gpt-4",
Status: "success",
Object: "chat.completion",
TokenUsageParsed: &schemas.BifrostLLMUsage{
PromptTokens: 120,
CompletionTokens: 45,
TotalTokens: 165,
},
}
require.NoError(t, entry.SerializeFields())
require.NoError(t, hybrid.CreateIfNotExists(ctx, entry))
waitForUploads(t, func() bool { return objStore.Len() == 1 })

dbLog, err := inner.FindByID(ctx, "chat-tokens-1")
require.NoError(t, err)
assert.Empty(t, dbLog.TokenUsage, "token_usage should be offloaded to object storage")
assert.Equal(t, 120, dbLog.PromptTokens)
assert.Equal(t, 45, dbLog.CompletionTokens)
assert.Equal(t, 165, dbLog.TotalTokens)

result, err := hybrid.SearchLogs(ctx, SearchFilters{}, PaginationOptions{Limit: 10})
require.NoError(t, err)
require.Len(t, result.Logs, 1)
listLog := result.Logs[0]
require.NotNil(t, listLog.TokenUsageParsed, "list query should rebuild token_usage from denormalized columns")
assert.Equal(t, 120, listLog.TokenUsageParsed.PromptTokens)
assert.Equal(t, 45, listLog.TokenUsageParsed.CompletionTokens)
assert.Equal(t, 165, listLog.TokenUsageParsed.TotalTokens)

found, err := hybrid.FindByID(ctx, "chat-tokens-1")
require.NoError(t, err)
require.NotNil(t, found.TokenUsageParsed, "detail view should hydrate full token_usage from S3")
assert.Equal(t, 165, found.TokenUsageParsed.TotalTokens)
}

func TestHybrid_SpeechInputSummaryForListPreview(t *testing.T) {
// /audio/speech (TTS) requests carry their text in speech_input, which is
// offloaded to object storage and cleared from the DB row. The DB must still
Expand Down
36 changes: 28 additions & 8 deletions framework/logstore/tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,14 @@ type Log struct {
StopReason *string `gorm:"type:varchar(50);index:idx_logs_stop_reason" json:"stop_reason,omitempty"` // Why the model stopped: "stop", "length", "content_filter", "tool_calls", etc.
ErrorDetails string `gorm:"type:text" json:"-"` // JSON serialized *schemas.BifrostError
Stream bool `gorm:"default:false" json:"stream"` // true if this was a streaming response
ContentSummary string `gorm:"type:text" json:"content_summary,omitempty"` // Last user message preview; UI log-list display fallback when payload fields are offloaded to object storage
RawRequest string `gorm:"type:text" json:"raw_request"` // Populated when `send-back-raw-request` is on
RawResponse string `gorm:"type:text" json:"raw_response"` // Populated when `send-back-raw-response` is on
PassthroughRequestBody string `gorm:"type:text" json:"passthrough_request_body,omitempty"` // Raw body for passthrough requests (UTF-8)
PassthroughResponseBody string `gorm:"type:text" json:"passthrough_response_body,omitempty"` // Raw body for passthrough responses (UTF-8)
RoutingEngineLogs string `gorm:"type:text" json:"routing_engine_logs,omitempty"` // Formatted routing engine decision logs
PluginLogs string `gorm:"type:text" json:"plugin_logs,omitempty"` // JSON serialized plugin log entries grouped by plugin name
Metadata *string `gorm:"type:text" json:"-"` // JSON serialized map[string]interface{}
ContentSummary string `gorm:"type:text" json:"content_summary,omitempty"` // Last user message preview; UI log-list display fallback when payload fields are offloaded to object storage
RawRequest string `gorm:"type:text" json:"raw_request"` // Populated when `send-back-raw-request` is on
RawResponse string `gorm:"type:text" json:"raw_response"` // Populated when `send-back-raw-response` is on
PassthroughRequestBody string `gorm:"type:text" json:"passthrough_request_body,omitempty"` // Raw body for passthrough requests (UTF-8)
PassthroughResponseBody string `gorm:"type:text" json:"passthrough_response_body,omitempty"` // Raw body for passthrough responses (UTF-8)
RoutingEngineLogs string `gorm:"type:text" json:"routing_engine_logs,omitempty"` // Formatted routing engine decision logs
PluginLogs string `gorm:"type:text" json:"plugin_logs,omitempty"` // JSON serialized plugin log entries grouped by plugin name
Metadata *string `gorm:"type:text" json:"-"` // JSON serialized map[string]interface{}
IsLargePayloadRequest bool `gorm:"default:false" json:"is_large_payload_request"`
IsLargePayloadResponse bool `gorm:"default:false" json:"is_large_payload_response"`
HasObject bool `gorm:"default:false" json:"-"` // True when payload is stored in object storage
Expand Down Expand Up @@ -914,6 +914,26 @@ func (l *Log) DeserializeFields() error {
l.RoutingEnginesUsed = []string{}
}

// Hybrid log store offloads token_usage to object storage but keeps denormalized
// prompt/completion/total/cached columns in the DB for analytics. Rebuild the virtual
// field so list APIs and the UI can render tokens without hydrating from S3 —
// same role content_summary plays for message previews. Only the cached-read detail
// is denormalized; richer details (e.g. completion_tokens_details) live solely in the
// offloaded payload and are restored on detail reads that hydrate from object storage.
if l.TokenUsage == "" && l.TokenUsageParsed == nil && (l.PromptTokens != 0 || l.CompletionTokens != 0 || l.TotalTokens != 0) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
usage := &schemas.BifrostLLMUsage{
PromptTokens: l.PromptTokens,
CompletionTokens: l.CompletionTokens,
TotalTokens: l.TotalTokens,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
if l.CachedReadTokens != 0 {
usage.PromptTokensDetails = &schemas.ChatPromptTokensDetails{
CachedReadTokens: l.CachedReadTokens,
}
}
l.TokenUsageParsed = usage
}

return nil
}

Expand Down
53 changes: 53 additions & 0 deletions framework/logstore/tables_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package logstore

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDeserializeFieldsReconstructsTokenUsageFromDenormalizedColumns(t *testing.T) {
log := &Log{
PromptTokens: 10,
CompletionTokens: 5,
TotalTokens: 15,
}
require.NoError(t, log.DeserializeFields())
require.NotNil(t, log.TokenUsageParsed)
assert.Equal(t, 10, log.TokenUsageParsed.PromptTokens)
assert.Equal(t, 5, log.TokenUsageParsed.CompletionTokens)
assert.Equal(t, 15, log.TokenUsageParsed.TotalTokens)
assert.Nil(t, log.TokenUsageParsed.PromptTokensDetails)
}

func TestDeserializeFieldsPrefersSerializedTokenUsage(t *testing.T) {
log := &Log{
TokenUsage: `{"prompt_tokens":99,"completion_tokens":1,"total_tokens":100}`,
PromptTokens: 10,
CompletionTokens: 5,
TotalTokens: 15,
}
require.NoError(t, log.DeserializeFields())
require.NotNil(t, log.TokenUsageParsed)
assert.Equal(t, 99, log.TokenUsageParsed.PromptTokens)
assert.Equal(t, 1, log.TokenUsageParsed.CompletionTokens)
assert.Equal(t, 100, log.TokenUsageParsed.TotalTokens)
}

func TestDeserializeFieldsDoesNotReconstructTokenUsageWhenSerializedValueIsMalformed(t *testing.T) {
log := &Log{
TokenUsage: `{"prompt_tokens":`,
PromptTokens: 10,
CompletionTokens: 5,
TotalTokens: 15,
}
require.NoError(t, log.DeserializeFields())
assert.Nil(t, log.TokenUsageParsed)
}

func TestDeserializeFieldsSkipsTokenUsageReconstructionWhenAllZero(t *testing.T) {
log := &Log{}
require.NoError(t, log.DeserializeFields())
assert.Nil(t, log.TokenUsageParsed)
}
34 changes: 17 additions & 17 deletions ui/app/workspace/logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import NumberFlow from "@number-flow/react";
import { useLocation } from "@tanstack/react-router";
import { AlertCircle, BarChart, CheckCircle, Clock, DollarSign, Hash, Info } from "lucide-react";
import { parseAsSafeString } from "@/lib/queryParamsParser";
import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { parseAsSafeArrayOf, parseAsSafeString } from "@/lib/queryParamsParser";
import { parseAsBoolean, parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

export default function LogsPage() {
Expand Down Expand Up @@ -72,20 +72,20 @@ export default function LogsPage() {
const [urlState, setUrlState] = useQueryStates(
{
parent_request_id: parseAsString.withDefault(""),
providers: parseAsArrayOf(parseAsString).withDefault([]),
models: parseAsArrayOf(parseAsString).withDefault([]),
aliases: parseAsArrayOf(parseAsString).withDefault([]),
status: parseAsArrayOf(parseAsString).withDefault([]),
stop_reasons: parseAsArrayOf(parseAsString).withDefault([]),
objects: parseAsArrayOf(parseAsString).withDefault([]),
selected_key_ids: parseAsArrayOf(parseAsString).withDefault([]),
virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]),
routing_rule_ids: parseAsArrayOf(parseAsString).withDefault([]),
routing_engine_used: parseAsArrayOf(parseAsString).withDefault([]),
user_ids: parseAsArrayOf(parseAsString).withDefault([]),
team_ids: parseAsArrayOf(parseAsString).withDefault([]),
customer_ids: parseAsArrayOf(parseAsString).withDefault([]),
business_unit_ids: parseAsArrayOf(parseAsString).withDefault([]),
providers: parseAsSafeArrayOf.withDefault([]),
models: parseAsSafeArrayOf.withDefault([]),
aliases: parseAsSafeArrayOf.withDefault([]),
status: parseAsSafeArrayOf.withDefault([]),
stop_reasons: parseAsSafeArrayOf.withDefault([]),
objects: parseAsSafeArrayOf.withDefault([]),
selected_key_ids: parseAsSafeArrayOf.withDefault([]),
virtual_key_ids: parseAsSafeArrayOf.withDefault([]),
routing_rule_ids: parseAsSafeArrayOf.withDefault([]),
routing_engine_used: parseAsSafeArrayOf.withDefault([]),
user_ids: parseAsSafeArrayOf.withDefault([]),
team_ids: parseAsSafeArrayOf.withDefault([]),
customer_ids: parseAsSafeArrayOf.withDefault([]),
business_unit_ids: parseAsSafeArrayOf.withDefault([]),
content_search: parseAsSafeString.withDefault(""),
start_time: parseAsInteger.withDefault(defaultTimeRange.startTime),
end_time: parseAsInteger.withDefault(defaultTimeRange.endTime),
Expand All @@ -96,7 +96,7 @@ export default function LogsPage() {
polling: parseAsBoolean.withDefault(true).withOptions({ clearOnDefault: false }),
period: parseAsString.withDefault(hasExplicitTimeRange ? "" : "1h").withOptions({ clearOnDefault: false }),
missing_cost_only: parseAsBoolean.withDefault(false),
cache_hit_types: parseAsArrayOf(parseAsString).withDefault([]),
cache_hit_types: parseAsSafeArrayOf.withDefault([]),
metadata_filters: parseAsString.withDefault(""),
selected_log: parseAsString.withDefault(""),
},
Expand Down
38 changes: 3 additions & 35 deletions ui/app/workspace/providers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ModelProviderConfig from "@/app/workspace/providers/views/modelProviderCo
import FullPageLoader from "@/components/fullPageLoader";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { TruncatedLabel } from "@/components/ui/truncatedLabel";
import { DefaultNetworkConfig, DefaultPerformanceConfig } from "@/lib/constants/config";
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import { ProviderLabels, ProviderNames } from "@/lib/constants/logs";
Expand All @@ -20,7 +21,7 @@ import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { useNavigate } from "@tanstack/react-router";
import { AlertCircle } from "lucide-react";
import { useQueryState } from "nuqs";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import AddCustomProviderSheet from "./dialogs/addNewCustomProviderSheet";
import ConfirmDeleteProviderDialog from "./dialogs/confirmDeleteProviderDialog";
Expand Down Expand Up @@ -227,7 +228,7 @@ export default function Providers() {
size="sm"
className="h-4 w-4 shrink-0"
/>
<TruncatedName name={label} />
<TruncatedLabel className="flex-1 text-sm">{label}</TruncatedLabel>
<KeyDiscoveryFailedBadge provider={p} />
<ProviderStatusBadge status={p.provider_status} />
{isCustom && (
Expand Down Expand Up @@ -272,39 +273,6 @@ export default function Providers() {
);
}

function TruncatedName({ name }: { name: string }) {
const textRef = useRef<HTMLDivElement>(null);
const [isTruncated, setIsTruncated] = useState(false);

const checkTruncation = useCallback(() => {
const el = textRef.current;
if (el) {
setIsTruncated(el.scrollWidth > el.clientWidth);
}
}, []);

useEffect(() => {
checkTruncation();
window.addEventListener("resize", checkTruncation);
return () => window.removeEventListener("resize", checkTruncation);
}, [checkTruncation, name]);

const inner = (
<div ref={textRef} className="min-w-0 flex-1 truncate text-sm">
{name}
</div>
);

if (!isTruncated) return inner;

return (
<Tooltip>
<TooltipTrigger asChild>{inner}</TooltipTrigger>
<TooltipContent side="right">{name}</TooltipContent>
</Tooltip>
);
}

function ProviderStatusBadge({ status }: { status: ProviderStatus }) {
return status != "active" ? (
<Tooltip>
Expand Down
3 changes: 2 additions & 1 deletion ui/components/filters/logsFilterSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Skeleton } from "@/components/ui/skeleton";
import { TruncatedLabel } from "@/components/ui/truncatedLabel";
import { RequestTypeLabels, RequestTypes, RoutingEngineUsedLabels, Statuses } from "@/lib/constants/logs";
import { useGetAvailableFilterDataQuery, useGetProvidersQuery } from "@/lib/store";
import type { LogFilters } from "@/lib/types/logs";
Expand Down Expand Up @@ -234,7 +235,7 @@ function CheckboxFilterItem({
return (
<label className="hover:bg-muted/50 flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm" data-testid={testId}>
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
<span className={cn("truncate", labelClassName)}>{label}</span>
<TruncatedLabel className={labelClassName}>{label}</TruncatedLabel>
</label>
);
}
Expand Down
3 changes: 2 additions & 1 deletion ui/components/filters/mcpFilterSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Skeleton } from "@/components/ui/skeleton";
import { TruncatedLabel } from "@/components/ui/truncatedLabel";
import { Statuses } from "@/lib/constants/logs";
import { useGetMCPLogsFilterDataQuery } from "@/lib/store";
import type { MCPToolLogFilters } from "@/lib/types/logs";
Expand Down Expand Up @@ -195,7 +196,7 @@ function CheckboxFilterItem({
return (
<label className="hover:bg-muted/50 flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm">
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
<span className={cn("truncate", labelClassName)}>{label}</span>
<TruncatedLabel className={labelClassName}>{label}</TruncatedLabel>
</label>
);
}
Expand Down
54 changes: 54 additions & 0 deletions ui/components/ui/truncatedLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from "react";
import { useCallback, useLayoutEffect, useState } from "react";

import { cn } from "@/lib/utils";

import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";

type TruncatedLabelProps = {
children: React.ReactNode;
className?: string;
tooltip?: React.ReactNode;
tooltipSide?: React.ComponentProps<typeof TooltipContent>["side"];
} & Omit<React.ComponentProps<"span">, "children">;

function TruncatedLabel({ children, className, tooltip, tooltipSide = "right", ...props }: TruncatedLabelProps) {
const [measureEl, setMeasureEl] = useState<HTMLSpanElement | null>(null);
const [isTruncated, setIsTruncated] = useState(false);

const setTextRef = useCallback((node: HTMLSpanElement | null) => {
setMeasureEl(node);
}, []);

useLayoutEffect(() => {
if (!measureEl) return;

const checkTruncation = () => {
setIsTruncated(measureEl.scrollWidth > measureEl.clientWidth);
};

checkTruncation();
const observer = new ResizeObserver(checkTruncation);
observer.observe(measureEl);
return () => observer.disconnect();
}, [measureEl, children]);

const tooltipContent = tooltip ?? (typeof children === "string" ? children : undefined);

const inner = (
<span ref={setTextRef} className={cn("min-w-0 truncate", className)} {...props}>
{children}
</span>
);

if (!isTruncated || tooltipContent == null) return inner;

return (
<Tooltip>
<TooltipTrigger asChild>{inner}</TooltipTrigger>
<TooltipContent side={tooltipSide}>{tooltipContent}</TooltipContent>
</Tooltip>
);
}

export { TruncatedLabel };
20 changes: 20 additions & 0 deletions ui/lib/queryParamsParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import { parseAsSafeArrayOf, parseAsSafeString } from "./queryParamsParser";

describe("parseAsSafeString", () => {
test("roundtrips model names with double slashes", () => {
const model = "gpt://b1g9n2rqsrnikgh0uofm/gpt-oss-120b";
const serialized = parseAsSafeString.serialize(model);
expect(serialized).not.toContain("://");
expect(parseAsSafeString.parse(serialized)).toBe(model);
});
});

describe("parseAsSafeArrayOf", () => {
test("roundtrips multiple models with special characters", () => {
const models = ["gpt://b1g9n2rqsrnikgh0uofm/gpt-oss-20b", "gpt://b1g9n2rqsrnikgh0uofm/gpt-oss-120b"];
const serialized = parseAsSafeArrayOf.serialize(models);
expect(serialized).not.toContain("://");
expect(parseAsSafeArrayOf.parse(serialized)).toEqual(models);
});
});
Loading
Loading