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
10 changes: 10 additions & 0 deletions backend/internal/application/billing/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ type UsagePricingInput struct {
CallCount int64
DurationBillable bool
DurationSeconds int64
MediaType string
InputImageCount int64
LatencyMS int64
ServerSideToolUsage map[string]int64
ServiceItems []ServiceUsageInput
Expand Down Expand Up @@ -1768,6 +1770,14 @@ func (s *Service) BuildUsageLedger(ctx context.Context, input UsagePricingInput)
"base_service_billed_nanousd": serviceBilledNanousd,
"service_items": usageServiceItemSnapshots(serviceItems),
}
if strings.EqualFold(strings.TrimSpace(input.MediaType), "video") {
inputImageCount := input.InputImageCount
if inputImageCount < 0 {
inputImageCount = 0
}
snapshot["media_type"] = "video"
snapshot["input_image_count"] = inputImageCount
}
if usageSource := strings.TrimSpace(input.UsageSource); usageSource != "" {
snapshot["usage_source"] = usageSource
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,22 @@ func TestBuildUsageLedgerBillsDurationOnlyWhenExplicitlyBillable(t *testing.T) {
PlatformModelName: "video-model",
DurationBillable: true,
DurationSeconds: 6,
MediaType: "video",
InputImageCount: 1,
})
if err != nil {
t.Fatalf("build video duration ledger: %v", err)
}
if video.DurationSeconds != 6 || video.BilledNanousd != 18 {
t.Fatalf("unexpected video duration billing: %#v", video)
}
var snapshot map[string]interface{}
if err := json.Unmarshal([]byte(video.PricingSnapshotJSON), &snapshot); err != nil {
t.Fatalf("unmarshal video pricing snapshot: %v", err)
}
if snapshot["media_type"] != "video" || snapshot["input_image_count"] != float64(1) {
t.Fatalf("unexpected video media snapshot: %#v", snapshot)
}
}

func TestAuthorizeUsageRejectsLegacyDurationPricingForNonVideoModel(t *testing.T) {
Expand Down
11 changes: 10 additions & 1 deletion backend/internal/application/conversation/service_billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ func (s *Service) buildSendMessageUsageLedger(ctx context.Context, input SendMes
if result == nil {
return nil, nil
}
isVideoGeneration := sendMessageResultIsVideoGeneration(result)
mediaType := ""
inputImageCount := int64(0)
if isVideoGeneration {
mediaType = "video"
inputImageCount, _ = countAttachmentKinds(result.UserMessage.Attachments)
}
latencyMS := result.LatencyMS
if latencyMS <= 0 {
latencyMS = result.AssistantMessage.LatencyMS
Expand Down Expand Up @@ -305,8 +312,10 @@ func (s *Service) buildSendMessageUsageLedger(ctx context.Context, input SendMes
OutputTokens: result.AssistantMessage.OutputTokens,
ReasoningTokens: result.AssistantMessage.ReasoningTokens,
CallCount: 1,
DurationBillable: sendMessageResultIsVideoGeneration(result),
DurationBillable: isVideoGeneration,
DurationSeconds: sendMessageBillingDurationSeconds(result),
MediaType: mediaType,
InputImageCount: inputImageCount,
LatencyMS: latencyMS,
ServerSideToolUsage: result.ServerSideToolUsage,
RawUsageJSON: result.RawUsageJSON,
Expand Down
95 changes: 80 additions & 15 deletions frontend/features/admin/components/sections/logs/admin-logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,20 @@ function formatUsageBalance(value: number | null | undefined, billingDisplay: Bi
return value === null || value === undefined ? "-" : formatBillingBalance(value, billingDisplay);
}

function usageBillableOutputTokens(item: AdminUsageLogDTO): number {
return item.outputTokens + item.reasoningTokens;
}

function usageTotalTokens(item: AdminUsageLogDTO): number {
return item.inputTokens + item.cacheReadTokens + item.cacheWriteTokens + item.outputTokens + item.reasoningTokens;
return item.inputTokens + item.cacheReadTokens + item.cacheWriteTokens + usageBillableOutputTokens(item);
}

type UsagePricingSnapshot = {
pricing_mode?: "token" | "call" | "duration" | "tiered" | string;
provider_protocol?: string;
duration_billable?: boolean;
media_type?: string;
input_image_count?: number;
cache_timeout?: string;
fast_mode?: boolean;
billing_speed?: string;
Expand Down Expand Up @@ -494,7 +501,7 @@ function buildUsageBillingTooltipLines(
const outputRate = readUsageSnapshotNumber(snapshot, "output_nanousd_per_m_tokens");
const cacheReadRate = readUsageSnapshotNumber(snapshot, "cache_read_nanousd_per_m_tokens");
const cacheWriteRate = readUsageSnapshotNumber(snapshot, "cache_write_nanousd_per_m_tokens");
const billedOutputTokens = item.outputTokens + item.reasoningTokens;
const billedOutputTokens = usageBillableOutputTokens(item);
const totalLine = usageTotalLine(item, labels, billingDisplay);
const cacheWriteLabel = cacheWriteBillingLabel(snapshot, labels.billingDisplay);
const cacheWriteNote = cacheWriteBillingNote(snapshot, labels.billingDisplay);
Expand Down Expand Up @@ -604,26 +611,84 @@ function UsageLogModelCell({ item, labels }: { item: AdminUsageLogDTO; labels: U
);
}

function UsageLogTokenCell({ item, locale }: { item: AdminUsageLogDTO; locale: string }) {
function UsageLogUsageCell({ item, locale }: { item: AdminUsageLogDTO; locale: string }) {
const t = useTranslations("adminLogs.usage.tokens");
const snapshot = parseUsagePricingSnapshot(item.pricingSnapshotJSON);
const isVideoUsage = snapshot.media_type === "video" || snapshot.duration_billable === true;
if (isVideoUsage) {
const inputImageCount = typeof snapshot.input_image_count === "number" && Number.isFinite(snapshot.input_image_count) && snapshot.input_image_count >= 0
? Math.trunc(snapshot.input_image_count)
: null;
const mediaUsage = [
{ label: t("input"), value: inputImageCount === null ? "—" : t("imageCount", { count: inputImageCount }) },
{ label: t("output"), value: t("secondCount", { count: item.durationSeconds }) },
];
return (
<div className="grid min-w-[10.5rem] gap-1">
{mediaUsage.map((entry) => (
<span
key={entry.label}
className="inline-flex h-5 items-center justify-between gap-2 rounded-md bg-muted/45 px-1.5 text-[11px] leading-none text-muted-foreground"
>
<span>{entry.label}</span>
<span className="font-mono tabular-nums">{entry.value}</span>
</span>
))}
</div>
);
}
const tokens = [
{ label: t("inputShort"), value: item.inputTokens },
{ label: t("outputShort"), value: item.outputTokens },
{
label: t("outputShort"),
value: usageBillableOutputTokens(item),
breakdown: {
visible: item.outputTokens,
reasoning: item.reasoningTokens,
},
},
{ label: t("cacheReadShort"), value: item.cacheReadTokens },
{ label: t("cacheWriteShort"), value: item.cacheWriteTokens },
];

return (
<div className="grid min-w-[10.5rem] grid-cols-2 gap-1">
{tokens.map((token) => (
<span
key={token.label}
className="inline-flex h-5 items-center justify-between gap-1 rounded-md bg-muted/45 px-1.5 font-mono text-[11px] leading-none text-muted-foreground"
>
<span>{token.label}</span>
<span className="tabular-nums">{formatCount(token.value, locale)}</span>
</span>
))}
{tokens.map((token) => {
const badge = (
<span className={cn(
"inline-flex h-5 items-center justify-between gap-1 rounded-md bg-muted/45 px-1.5 font-mono text-[11px] leading-none text-muted-foreground",
token.breakdown && "cursor-help",
)}>
<span>{token.label}</span>
<span className="tabular-nums">{formatCount(token.value, locale)}</span>
</span>
);
if (!token.breakdown) {
return <React.Fragment key={token.label}>{badge}</React.Fragment>;
}
return (
<Tooltip key={token.label}>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent side="top" className="min-w-40">
<div className="grid gap-1.5 text-xs">
<div className="flex items-center justify-between gap-5">
<span>{t("output")}</span>
<span className="font-mono tabular-nums">{formatCount(token.breakdown.visible, locale)}</span>
</div>
<div className="flex items-center justify-between gap-5">
<span>{t("reasoning")}</span>
<span className="font-mono tabular-nums">{formatCount(token.breakdown.reasoning, locale)}</span>
</div>
<Separator className="bg-background/20" />
<div className="flex items-center justify-between gap-5 font-medium">
<span>{t("outputTotal")}</span>
<span className="font-mono tabular-nums">{formatCount(token.value, locale)}</span>
</div>
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
Expand Down Expand Up @@ -1318,7 +1383,7 @@ function UsageLogTable({
<TableHead className="w-[72px]">ID</TableHead>
<TableHead>{t("columns.caller")}</TableHead>
<TableHead>{t("columns.model")}</TableHead>
<TableHead>Token</TableHead>
<TableHead>{t("columns.usage")}</TableHead>
<TableHead>{t("columns.billing")}</TableHead>
<TableHead>{t("columns.balanceAfter")}</TableHead>
<TableHead>{t("columns.latency")}</TableHead>
Expand All @@ -1340,7 +1405,7 @@ function UsageLogTable({
<UsageLogModelCell item={item} labels={usageLabels} />
</TableCell>
<TableCell>
<UsageLogTokenCell item={item} locale={locale} />
<UsageLogUsageCell item={item} locale={locale} />
</TableCell>
<TableCell><UsageLogCostCell item={item} labels={usageLabels} billingDisplay={billingDisplay} /></TableCell>
<TableCell className="whitespace-nowrap font-medium tabular-nums text-foreground">
Expand Down
21 changes: 1 addition & 20 deletions frontend/features/chat/components/message/message-bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -685,21 +685,12 @@ function MessageInlineVideoPreview({
const resolveErrorMessage = useLocalizedErrorMessage();
const objectURLRef = React.useRef<string | null>(null);
const [state, setState] = React.useState<InlineVideoPreviewState>({ status: "loading" });
const [detectedDurationSeconds, setDetectedDurationSeconds] = React.useState<number | undefined>();
const fileID = attachment.fileID;
const fileName = attachment.fileName;
const mimeType = attachment.mimeType;
const detectedMime = attachment.detectedMime;
const previewURL = attachment.previewURL;
const sizeBytes = attachment.sizeBytes;
const displayDurationSeconds = attachment.durationSeconds ?? detectedDurationSeconds;

const handleDurationChange = React.useCallback((durationSeconds: number) => {
setDetectedDurationSeconds(
Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.ceil(durationSeconds) : undefined,
);
}, []);

const revokeObjectURL = React.useCallback(() => {
if (!objectURLRef.current) {
return;
Expand All @@ -711,7 +702,6 @@ function MessageInlineVideoPreview({
React.useEffect(() => {
let cancelled = false;
revokeObjectURL();
setDetectedDurationSeconds(undefined);

if (previewURL) {
setState({
Expand Down Expand Up @@ -795,23 +785,14 @@ function MessageInlineVideoPreview({
}

return (
<div className="relative my-4 w-full max-w-[40rem]">
<div className="my-4 w-full max-w-[40rem]">
<PreviewMedia
kind="video"
source={state.source}
alt={attachment.fileName}
contentType={state.contentType}
inline
onDurationChange={handleDurationChange}
/>
{displayDurationSeconds && displayDurationSeconds > 0 ? (
<span
className="pointer-events-none absolute left-3 top-3 rounded-md bg-black/65 px-2 py-1 text-xs font-medium tabular-nums text-white backdrop-blur-sm"
title={tMessages("videoDuration", { seconds: displayDurationSeconds })}
>
{displayDurationSeconds}s
</span>
) : null}
</div>
);
}
19 changes: 7 additions & 12 deletions frontend/features/chat/hooks/use-chat-message-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { ChatSubmitBlockReason } from "@/features/chat/model/chat-task";
import { resolveChatSubmitDecision } from "@/features/chat/model/chat-task";
import { useHiddenQueuedParentRuns } from "@/features/chat/hooks/use-hidden-queued-parent-runs";
import {
resolveAssistantInputSideUsageValue,
resolveDefaultSubmissionParentMessage,
resolvePersistedPublicID,
toPendingAttachments,
Expand Down Expand Up @@ -102,15 +103,6 @@ function streamEventErrorToApiError(
return new ApiError(event.message || fallback, 502, event.debug, event.errorCode);
}

function resolveInputSideUsageValue(...values: Array<number | null | undefined>): number {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}

function resolveMediaStatusLabel(
status: string,
fallbackMessage: string,
Expand Down Expand Up @@ -1195,18 +1187,21 @@ export function useChatMessageSubmit({
assistantUpdatedAt: completed.assistantMessage.updatedAt,
assistantContentType: completed.assistantMessage.contentType || current.assistantContentType,
assistantAttachments: parseAttachments(completed.assistantMessage.attachments),
assistantInputTokens: resolveInputSideUsageValue(
assistantInputTokens: resolveAssistantInputSideUsageValue(
assistantOnlyBranch,
completed.assistantMessage.inputTokens,
completed.userMessage.inputTokens,
current.assistantInputTokens,
),
assistantOutputTokens: completed.assistantMessage.outputTokens,
assistantCacheReadTokens: resolveInputSideUsageValue(
assistantCacheReadTokens: resolveAssistantInputSideUsageValue(
assistantOnlyBranch,
completed.assistantMessage.cacheReadTokens,
completed.userMessage.cacheReadTokens,
current.assistantCacheReadTokens,
),
assistantCacheWriteTokens: resolveInputSideUsageValue(
assistantCacheWriteTokens: resolveAssistantInputSideUsageValue(
assistantOnlyBranch,
completed.assistantMessage.cacheWriteTokens,
completed.userMessage.cacheWriteTokens,
current.assistantCacheWriteTokens,
Expand Down
6 changes: 6 additions & 0 deletions frontend/features/chat/model/chat-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,12 @@ export function buildVisibleMessages(
if (item.role !== "assistant") {
return item;
}
// Assistant-only retries reuse the original user message, but own the
// prompt-side usage for their generation. A zero value is authoritative
// and must not fall back to the reused user's first-run usage.
if (item.branchReason === "retry" && item.sourcePublicID?.trim()) {
return item;
}
const previous = index > 0 ? withBranchNavigators[index - 1] : null;
if (!previous || previous.role !== "user") {
return item;
Expand Down
19 changes: 19 additions & 0 deletions frontend/features/chat/model/message-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ export function resolvePersistedPublicID(value: string | null | undefined): stri
return normalized;
}

export function resolveAssistantInputSideUsageValue(
assistantOwnsUsage: boolean,
assistantValue: number | null | undefined,
userValue: number | null | undefined,
liveValue: number | null | undefined,
): number {
if (assistantOwnsUsage) {
return typeof assistantValue === "number" && Number.isFinite(assistantValue) && assistantValue >= 0
? assistantValue
: 0;
}
for (const value of [assistantValue, userValue, liveValue]) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}

function isSuccessfulContextMessage(message: ChatAreaMessage): boolean {
const status = message.status?.trim().toLowerCase() || "success";
return (
Expand Down
9 changes: 8 additions & 1 deletion frontend/i18n/messages/en-US/admin-logs.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"message": "Message",
"caller": "Caller",
"model": "Model",
"usage": "Usage",
"billing": "Billing",
"balanceAfter": "Balance after settlement",
"latency": "Latency",
Expand Down Expand Up @@ -203,7 +204,13 @@
"inputShort": "In",
"outputShort": "Out",
"cacheReadShort": "Read",
"cacheWriteShort": "Write"
"cacheWriteShort": "Write",
"input": "Input",
"output": "Output",
"reasoning": "Reasoning",
"outputTotal": "Output total",
"imageCount": "{count, plural, one {# image} other {# images}}",
"secondCount": "{count, plural, one {# second} other {# seconds}}"
},
"billing": {
"cacheWrite": "Cache write",
Expand Down
1 change: 0 additions & 1 deletion frontend/i18n/messages/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,6 @@
"contextCompressed": "Context was automatically compressed",
"scrollToBottom": "Scroll to bottom",
"processing": "Processing...",
"videoDuration": "Video duration: {seconds} seconds",
"expandUserMessage": "Expand",
"collapseUserMessage": "Collapse",
"editCreatesBranch": "Saving creates a new branch in this conversation.",
Expand Down
Loading
Loading