diff --git a/apps/admin/src/lib/api/v1.d.ts b/apps/admin/src/lib/api/v1.d.ts index f862783a15..0468f37d52 100644 --- a/apps/admin/src/lib/api/v1.d.ts +++ b/apps/admin/src/lib/api/v1.d.ts @@ -569,6 +569,157 @@ export interface paths { patch?: never; trace?: never; }; + "/logs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Single log entry */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + log: { + id: string; + requestId: string; + createdAt: string; + updatedAt: string; + organizationId: string; + projectId: string; + apiKeyId: string; + duration: number; + requestedModel: string; + requestedProvider: string | null; + usedModel: string; + usedProvider: string; + responseSize: number; + content: string | null; + reasoningContent: string | null; + unifiedFinishReason: string | null; + finishReason: string | null; + promptTokens: string | null; + completionTokens: string | null; + totalTokens: string | null; + reasoningTokens: string | null; + messages?: unknown; + temperature: number | null; + maxTokens: number | null; + topP: number | null; + frequencyPenalty: number | null; + presencePenalty: number | null; + reasoningEffort: string | null; + responseFormat?: unknown; + tools: ({ + /** @enum {string} */ + type: "function"; + function: { + name: string; + description?: string; + parameters?: { + [key: string]: unknown; + }; + }; + } | { + /** @enum {string} */ + type: "web_search"; + user_location?: { + city?: string; + region?: string; + country?: string; + timezone?: string; + }; + /** @enum {string} */ + search_context_size?: "low" | "medium" | "high"; + max_uses?: number; + })[] | null; + toolChoice: "none" | "auto" | "required" | { + /** @enum {string} */ + type: "function"; + function: { + name: string; + }; + } | unknown; + toolResults: { + id: string; + /** @enum {string} */ + type: "function"; + function: { + name: string; + arguments: string; + }; + }[] | null; + hasError: boolean | null; + errorDetails: { + statusCode: number; + statusText: string; + responseText: string; + } | null; + cost: number | null; + inputCost: number | null; + outputCost: number | null; + requestCost: number | null; + imageInputTokens: string | null; + imageOutputTokens: string | null; + imageInputCost: number | null; + imageOutputCost: number | null; + estimatedCost: boolean | null; + canceled: boolean | null; + streamed: boolean | null; + cached: boolean | null; + customHeaders?: unknown; + /** @enum {string} */ + mode: "api-keys" | "credits" | "hybrid"; + /** @enum {string} */ + usedMode: "api-keys" | "credits"; + source: string | null; + routingMetadata?: { + availableProviders?: string[]; + selectedProvider?: string; + selectionReason?: string; + providerScores?: { + providerId: string; + score: number; + uptime?: number; + latency?: number; + price?: number; + }[]; + } | null; + }; + }; + }; + }; + /** @description Log not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/logs": { parameters: { query?: never; @@ -815,6 +966,7 @@ export interface paths { requestCount: number; inputTokens: number; outputTokens: number; + cachedTokens: number; totalTokens: number; cost: number; inputCost: number; @@ -823,6 +975,7 @@ export interface paths { dataStorageCost: number; imageInputCost: number; imageOutputCost: number; + cachedInputCost: number; errorCount: number; errorRate: number; cacheCount: number; diff --git a/apps/api/src/routes/activity.ts b/apps/api/src/routes/activity.ts index 771ed9ca05..76f5eaf538 100644 --- a/apps/api/src/routes/activity.ts +++ b/apps/api/src/routes/activity.ts @@ -27,6 +27,7 @@ const dailyActivitySchema = z.object({ requestCount: z.number(), inputTokens: z.number(), outputTokens: z.number(), + cachedTokens: z.number(), totalTokens: z.number(), cost: z.number(), inputCost: z.number(), @@ -35,6 +36,7 @@ const dailyActivitySchema = z.object({ dataStorageCost: z.number(), imageInputCost: z.number(), imageOutputCost: z.number(), + cachedInputCost: z.number(), errorCount: z.number(), errorRate: z.number(), cacheCount: z.number(), @@ -137,6 +139,10 @@ activity.openapi(getActivity, async (c) => { sql`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as( "outputTokens", ), + cachedTokens: + sql`COALESCE(SUM(CAST(${tables.log.cachedTokens} AS NUMERIC)), 0)`.as( + "cachedTokens", + ), totalTokens: sql`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as( "totalTokens", @@ -163,6 +169,10 @@ activity.openapi(getActivity, async (c) => { sql`COALESCE(SUM(${tables.log.imageOutputCost}), 0)`.as( "imageOutputCost", ), + cachedInputCost: + sql`COALESCE(SUM(${tables.log.cachedInputCost}), 0)`.as( + "cachedInputCost", + ), errorCount: sql`SUM(CASE WHEN ${tables.log.hasError} = true THEN 1 ELSE 0 END)`.as( "errorCount", @@ -257,6 +267,7 @@ activity.openapi(getActivity, async (c) => { const requestCount = Number(day.requestCount); const inputTokens = Number(day.inputTokens); const outputTokens = Number(day.outputTokens); + const cachedTokens = Number(day.cachedTokens); const totalTokens = Number(day.totalTokens); const cost = Number(day.cost); const inputCost = Number(day.inputCost); @@ -265,6 +276,7 @@ activity.openapi(getActivity, async (c) => { const dataStorageCost = Number(day.dataStorageCost); const imageInputCost = Number(day.imageInputCost); const imageOutputCost = Number(day.imageOutputCost); + const cachedInputCost = Number(day.cachedInputCost); const errorCount = Number(day.errorCount); const cacheCount = Number(day.cacheCount); const discountSavings = Number(day.discountSavings); @@ -277,6 +289,7 @@ activity.openapi(getActivity, async (c) => { requestCount, inputTokens, outputTokens, + cachedTokens, totalTokens, cost, inputCost, @@ -285,6 +298,7 @@ activity.openapi(getActivity, async (c) => { dataStorageCost, imageInputCost, imageOutputCost, + cachedInputCost, errorCount, errorRate, cacheCount, diff --git a/apps/api/src/routes/logs.ts b/apps/api/src/routes/logs.ts index d6e1362abf..32db086bda 100644 --- a/apps/api/src/routes/logs.ts +++ b/apps/api/src/routes/logs.ts @@ -102,6 +102,59 @@ const logSchema = z.object({ .optional(), }); +// GET /logs/:id - Fetch a single log by ID +const getById = createRoute({ + method: "get", + path: "/{id}", + request: { + params: z.object({ + id: z.string().openapi({ description: "Log ID" }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ log: logSchema }), + }, + }, + description: "Single log entry", + }, + 404: { + description: "Log not found", + }, + }, +}); + +logs.openapi(getById, async (c) => { + const user = c.get("user"); + + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { id } = c.req.valid("param"); + + const log = await db.query.log.findFirst({ + where: { id }, + }); + + if (!log) { + throw new HTTPException(404, { message: "Log not found" }); + } + + // Verify user has access to this log's organization + const organizationIds = await getActiveUserOrganizationIds(user.id); + + if (!organizationIds.includes(log.organizationId)) { + throw new HTTPException(403, { + message: "You don't have access to this log", + }); + } + + return c.json({ log }); +}); + const querySchema = z.object({ apiKeyId: z.string().optional().openapi({ description: "Filter logs by API key ID", diff --git a/apps/code/src/lib/api/v1.d.ts b/apps/code/src/lib/api/v1.d.ts index f862783a15..0468f37d52 100644 --- a/apps/code/src/lib/api/v1.d.ts +++ b/apps/code/src/lib/api/v1.d.ts @@ -569,6 +569,157 @@ export interface paths { patch?: never; trace?: never; }; + "/logs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Single log entry */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + log: { + id: string; + requestId: string; + createdAt: string; + updatedAt: string; + organizationId: string; + projectId: string; + apiKeyId: string; + duration: number; + requestedModel: string; + requestedProvider: string | null; + usedModel: string; + usedProvider: string; + responseSize: number; + content: string | null; + reasoningContent: string | null; + unifiedFinishReason: string | null; + finishReason: string | null; + promptTokens: string | null; + completionTokens: string | null; + totalTokens: string | null; + reasoningTokens: string | null; + messages?: unknown; + temperature: number | null; + maxTokens: number | null; + topP: number | null; + frequencyPenalty: number | null; + presencePenalty: number | null; + reasoningEffort: string | null; + responseFormat?: unknown; + tools: ({ + /** @enum {string} */ + type: "function"; + function: { + name: string; + description?: string; + parameters?: { + [key: string]: unknown; + }; + }; + } | { + /** @enum {string} */ + type: "web_search"; + user_location?: { + city?: string; + region?: string; + country?: string; + timezone?: string; + }; + /** @enum {string} */ + search_context_size?: "low" | "medium" | "high"; + max_uses?: number; + })[] | null; + toolChoice: "none" | "auto" | "required" | { + /** @enum {string} */ + type: "function"; + function: { + name: string; + }; + } | unknown; + toolResults: { + id: string; + /** @enum {string} */ + type: "function"; + function: { + name: string; + arguments: string; + }; + }[] | null; + hasError: boolean | null; + errorDetails: { + statusCode: number; + statusText: string; + responseText: string; + } | null; + cost: number | null; + inputCost: number | null; + outputCost: number | null; + requestCost: number | null; + imageInputTokens: string | null; + imageOutputTokens: string | null; + imageInputCost: number | null; + imageOutputCost: number | null; + estimatedCost: boolean | null; + canceled: boolean | null; + streamed: boolean | null; + cached: boolean | null; + customHeaders?: unknown; + /** @enum {string} */ + mode: "api-keys" | "credits" | "hybrid"; + /** @enum {string} */ + usedMode: "api-keys" | "credits"; + source: string | null; + routingMetadata?: { + availableProviders?: string[]; + selectedProvider?: string; + selectionReason?: string; + providerScores?: { + providerId: string; + score: number; + uptime?: number; + latency?: number; + price?: number; + }[]; + } | null; + }; + }; + }; + }; + /** @description Log not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/logs": { parameters: { query?: never; @@ -815,6 +966,7 @@ export interface paths { requestCount: number; inputTokens: number; outputTokens: number; + cachedTokens: number; totalTokens: number; cost: number; inputCost: number; @@ -823,6 +975,7 @@ export interface paths { dataStorageCost: number; imageInputCost: number; imageOutputCost: number; + cachedInputCost: number; errorCount: number; errorRate: number; cacheCount: number; diff --git a/apps/playground/src/components/credits/top-up-credits-dialog.tsx b/apps/playground/src/components/credits/top-up-credits-dialog.tsx index 70be911353..c9d3497eaf 100644 --- a/apps/playground/src/components/credits/top-up-credits-dialog.tsx +++ b/apps/playground/src/components/credits/top-up-credits-dialog.tsx @@ -39,11 +39,19 @@ export function TopUpCreditsButton() { } interface TopUpCreditsDialogProps { - children: React.ReactNode; + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } -export function TopUpCreditsDialog({ children }: TopUpCreditsDialogProps) { - const [open, setOpen] = useState(false); +export function TopUpCreditsDialog({ + children, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, +}: TopUpCreditsDialogProps) { + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = controlledOnOpenChange ?? setInternalOpen; const [step, setStep] = useState< "amount" | "payment" | "select-payment" | "confirm-payment" | "success" >("amount"); @@ -97,7 +105,7 @@ export function TopUpCreditsDialog({ children }: TopUpCreditsDialogProps) { return ( - {children} + {children ? {children} : null} {step === "amount" ? ( (null); + const [showTopUp, setShowTopUp] = useState(false); // MCP servers management const { @@ -618,6 +620,11 @@ export default function ChatPageClient({ image_url: { url: string }; }>, ) => { + if (selectedOrganization && Number(selectedOrganization.credits) <= 0) { + setShowTopUp(true); + return; + } + setError(null); setIsLoading(true); @@ -1061,6 +1068,7 @@ export default function ChatPageClient({ + ); diff --git a/apps/playground/src/components/playground/chat-ui.tsx b/apps/playground/src/components/playground/chat-ui.tsx index 116defad19..35b9db66d7 100644 --- a/apps/playground/src/components/playground/chat-ui.tsx +++ b/apps/playground/src/components/playground/chat-ui.tsx @@ -1,5 +1,5 @@ "use client"; -import { AlertCircle, RefreshCcw, Copy, Brain, GlobeIcon } from "lucide-react"; +import { RefreshCcw, Copy, Brain, GlobeIcon } from "lucide-react"; import { useRef, useState, useEffect, useCallback, memo, useMemo } from "react"; import { toast } from "sonner"; @@ -55,7 +55,6 @@ import { ToolInput, ToolOutput, } from "@/components/ai-elements/tool"; -import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { ImageZoom } from "@/components/ui/image-zoom"; import { @@ -606,12 +605,6 @@ export const ChatUI = ({ : undefined } > - {error && ( - - - {error} - - )} { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }} + className="inline-flex items-center text-muted-foreground/50 hover:text-muted-foreground transition-colors" + > + {copied ? : } + + ); +} + +function Section({ + title, + children, + className, +}: { + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function Field({ + label, + value, + mono, + muted, +}: { + label: string; + value: React.ReactNode; + mono?: boolean; + muted?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function StatusIndicator({ log }: { log: Partial }) { + let StatusIcon = CheckCircle2; + let color = "text-emerald-500"; + let bgColor = "bg-emerald-500/10"; + let label = "Completed"; + + if (log.hasError || log.unifiedFinishReason === "error") { + StatusIcon = AlertCircle; + color = "text-red-500"; + bgColor = "bg-red-500/10"; + label = "Error"; + } else if (log.unifiedFinishReason === "content_filter") { + StatusIcon = TriangleAlert; + color = "text-amber-500"; + bgColor = "bg-amber-500/10"; + label = "Content Filter"; + } else if ( + log.unifiedFinishReason !== "completed" && + log.unifiedFinishReason !== "tool_calls" + ) { + StatusIcon = AlertCircle; + color = "text-yellow-500"; + bgColor = "bg-yellow-500/10"; + label = log.unifiedFinishReason || "Unknown"; + } else if (log.unifiedFinishReason === "tool_calls") { + label = "Tool Calls"; + } + + return ( +
+ + {label} +
+ ); +} + +function formatDuration(ms: number) { + if (ms < 1000) { + return `${ms}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +export function LogDetailClient({ + initialData, + orgId, + projectId, + logId, +}: LogDetailClientProps) { + const api = useApi(); + + const { data } = api.useQuery( + "get", + "/logs/{id}", + { params: { path: { id: logId } } }, + { + initialData: initialData + ? { + log: { + ...initialData.log, + createdAt: + initialData.log.createdAt instanceof Date + ? initialData.log.createdAt.toISOString() + : initialData.log.createdAt, + updatedAt: + initialData.log.updatedAt instanceof Date + ? initialData.log.updatedAt.toISOString() + : initialData.log.updatedAt, + }, + } + : undefined, + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + }, + ); + + if (!data?.log) { + return ( +
+

Log not found

+ +
+ ); + } + + const log = { + ...data.log, + createdAt: new Date(data.log.createdAt), + updatedAt: new Date(data.log.updatedAt), + } as Log; + + const retentionEnabled = + log.dataStorageCost !== null && + log.dataStorageCost !== undefined && + Number(log.dataStorageCost) > 0; + + const throughput = + log.duration && log.totalTokens + ? (Number(log.totalTokens) / (log.duration / 1000)).toFixed(1) + : null; + + return ( +
+
+ {/* Header */} +
+ + +
+
+
+

+ {log.usedModel} +

+ +
+
+ + {format(log.createdAt, "MMM d, yyyy 'at' HH:mm:ss")} + + + ({formatDistanceToNow(log.createdAt, { addSuffix: true })}) + +
+
+
+
+ + {/* Quick stats row */} +
+
+
+ + Duration +
+

+ {formatDuration(log.duration ?? 0)} +

+
+
+
+ + Tokens +
+

+ {Number(log.totalTokens || 0).toLocaleString()} +

+
+
+
+ + Throughput +
+

+ {throughput ? `${throughput} t/s` : "-"} +

+
+ {log.timeToFirstToken && ( +
+
+ + TTFT +
+

+ {formatDuration(log.timeToFirstToken)} +

+
+ )} +
+ + + +
+
+ + Inference Cost + +
+

+ ${log.cost?.toFixed(6) || "0"} +

+
+
+ +

Provider cost — not deducted from your balance

+
+
+
+
+
+
+ + Cache +
+

+ {log.cached + ? "Full" + : log.cachedTokens && Number(log.cachedTokens) > 0 + ? "Partial" + : "None"} +

+
+
+ + {/* Main content grid */} +
+ {/* Left column */} +
+
+
+ + + {log.usedModelMapping && ( + + )} + + {log.requestedProvider && ( + + )} + + + Yes + + ) : ( + "No" + ) + } + /> + + + Yes + + ) : ( + "No" + ) + } + /> + {log.source && } +
+
+ + {log.routingMetadata && ( +
+
+ {log.routingMetadata.selectionReason && ( + + )} + {log.routingMetadata.availableProviders && + log.routingMetadata.availableProviders.length > 0 && ( + + )} + {log.routingMetadata.providerScores && + log.routingMetadata.providerScores.length > 0 && ( +
+

+ Provider Scores +

+
+ {log.routingMetadata.providerScores.map((score) => ( +
+ {score.providerId} + + {score.score.toFixed(2)} + {score.uptime !== undefined && ( + + {score.uptime?.toFixed(0)}% up + + )} + {score.latency !== undefined && ( + + {score.latency?.toFixed(0)}ms + + )} + +
+ ))} +
+
+ )} +
+
+ )} + +
+
+
+

+ Provider pricing — not deducted from your balance +

+
+ + + {!!log.cachedInputCost && + Number(log.cachedInputCost) > 0 && ( + + )} + + {!!log.webSearchCost && Number(log.webSearchCost) > 0 && ( + + )} + {!!log.imageInputCost && Number(log.imageInputCost) > 0 && ( + + )} + {!!log.imageOutputCost && + Number(log.imageOutputCost) > 0 && ( + + )} + + {log.discount && log.discount !== 1 && ( + + {(log.discount * 100).toFixed(0)}% off + + } + /> + )} + {log.pricingTier && ( + + )} +
+
+
+

+ Billed to your organization +

+ + {log.usedMode === "api-keys" && ( + + )} +
+
+
+
+ + {/* Right column */} +
+
+
+ + + + {log.cachedTokens && Number(log.cachedTokens) > 0 && ( + + )} + {log.reasoningTokens && ( + + )} + {log.imageInputTokens && Number(log.imageInputTokens) > 0 && ( + + )} + {log.imageOutputTokens && Number(log.imageOutputTokens) > 0 && ( + + )} + +
+
+ +
+
+ + + + + + + {log.effort && } + + + + +
+
+ +
+
+ + {log.requestId} + + + } + /> + + {log.id} + + + } + /> + {log.projectId} + } + /> + {log.apiKeyId} + } + /> + + + +
+ {log.customHeaders && + Object.keys(log.customHeaders).length > 0 && ( +
+

+ Custom Headers +

+ {Object.entries(log.customHeaders).map(([key, value]) => ( + + ))} +
+ )} +
+
+
+ + {/* Full-width sections */} + {log.plugins && log.plugins.length > 0 && ( +
+
+
+ {log.plugins.map((plugin) => ( + + + {plugin} + + ))} +
+ {log.pluginResults?.responseHealing && ( +
+
+ + + Response Healing:{" "} + {log.pluginResults.responseHealing.healed ? ( + + Applied + {log.pluginResults.responseHealing.healingMethod && ( + + ( + {log.pluginResults.responseHealing.healingMethod + .replace(/_/g, " ") + .replace(/\b\w/g, (l: string) => + l.toUpperCase(), + )} + ) + + )} + + ) : ( + + Not needed + + )} + +
+
+ )} +
+
+ )} + + {(log.tools || log.toolChoice || log.toolResults) && ( +
+
+ {log.tools && ( +
+

+ Available Tools +

+
+										{JSON.stringify(log.tools, null, 2)}
+									
+
+ )} + {log.toolChoice && ( +
+

+ Tool Choice +

+
+										{JSON.stringify(log.toolChoice, null, 2)}
+									
+
+ )} + {log.toolResults && ( +
+

+ Tool Calls +

+
+ {Array.isArray(log.toolResults) ? ( + log.toolResults + .filter( + (tc): tc is NonNullable => + tc !== null && tc !== undefined, + ) + .map((toolCall, index: number) => ( +
+
+ + {toolCall.function?.name || "Unknown"} + + + {toolCall.id || "N/A"} + +
+ {toolCall.function?.arguments && ( +
+																{typeof toolCall.function.arguments === "string"
+																	? toolCall.function.arguments
+																	: JSON.stringify(
+																			toolCall.function.arguments,
+																			null,
+																			2,
+																		)}
+															
+ )} +
+ )) + ) : ( +
+												{JSON.stringify(log.toolResults, null, 2)}
+											
+ )} +
+
+ )} +
+
+ )} + + {!!log.webSearchCost && Number(log.webSearchCost) > 0 && ( +
+
+
+ + Web search was used in this request + + Cost: ${Number(log.webSearchCost).toFixed(4)} + +
+
+
+ )} + + {log.hasError && !!log.errorDetails && ( +
+
+
+
+

Status Code

+

+ {log.errorDetails.statusCode} +

+
+
+

Status Text

+

+ {log.errorDetails.statusText} +

+
+
+
+

Error Message

+
+									{log.errorDetails.responseText}
+								
+
+
+
+ )} + +
+
+ {log.messages ? ( +
+								{JSON.stringify(log.messages, null, 2)}
+							
+ ) : !retentionEnabled ? ( +

+ Message data not retained. Enable retention in organization + policies to store request messages. +

+ ) : ( +

+ No message data available. +

+ )} + {!!log.responseFormat && ( +
+

+ Response Format +

+
+									{JSON.stringify(log.responseFormat, null, 2)}
+								
+
+ )} +
+
+ + {log.reasoningContent && ( +
+
+
+								{log.reasoningContent}
+							
+
+
+ )} + +
+
+ {log.content ? ( +
+								{log.content}
+							
+ ) : !retentionEnabled ? ( +

+ Response content not retained. Enable retention in organization + policies to store response data. +

+ ) : ( +

+ No response content available. +

+ )} +
+
+ + {log.params && Object.keys(log.params).length > 0 && ( +
+
+
+								{JSON.stringify(log.params, null, 2)}
+							
+
+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/[logId]/page.tsx b/apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/[logId]/page.tsx new file mode 100644 index 0000000000..2153c10faa --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/[logId]/page.tsx @@ -0,0 +1,28 @@ +import { fetchServerData } from "@/lib/server-api"; + +import { LogDetailClient } from "./log-detail-client"; + +import type { Log } from "@llmgateway/db"; + +export default async function LogDetailPage({ + params, +}: { + params: Promise<{ orgId: string; projectId: string; logId: string }>; +}) { + const { orgId, projectId, logId } = await params; + + const initialData = await fetchServerData<{ log: Log }>("GET", "/logs/{id}", { + params: { + path: { id: logId }, + }, + }); + + return ( + + ); +} diff --git a/apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/page.tsx b/apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/page.tsx index 56c9a626bd..2443db0bb9 100644 --- a/apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/page.tsx +++ b/apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/page.tsx @@ -20,7 +20,7 @@ export default async function ActivityPage({ limit?: string; }>; }) { - const { projectId } = await params; + const { orgId, projectId } = await params; const searchParamsData = await searchParams; // Build query parameters for logs - same as client-side @@ -78,6 +78,7 @@ export default async function ActivityPage({ diff --git a/apps/ui/src/components/activity/recent-logs.tsx b/apps/ui/src/components/activity/recent-logs.tsx index 21aeb94b92..5e04b82de7 100644 --- a/apps/ui/src/components/activity/recent-logs.tsx +++ b/apps/ui/src/components/activity/recent-logs.tsx @@ -45,9 +45,10 @@ interface RecentLogsProps { } | undefined; projectId: string | null; + orgId?: string | null; } -export function RecentLogs({ initialData, projectId }: RecentLogsProps) { +export function RecentLogs({ initialData, projectId, orgId }: RecentLogsProps) { const router = useRouter(); const searchParams = useSearchParams(); @@ -366,6 +367,8 @@ export function RecentLogs({ initialData, projectId }: RecentLogsProps) { toolChoice: log.toolChoice as any, customHeaders: log.customHeaders as any, }} + orgId={orgId || undefined} + projectId={projectId || undefined} /> ))} diff --git a/apps/ui/src/components/dashboard/dashboard-client.tsx b/apps/ui/src/components/dashboard/dashboard-client.tsx index 7231207c6c..197f19756f 100644 --- a/apps/ui/src/components/dashboard/dashboard-client.tsx +++ b/apps/ui/src/components/dashboard/dashboard-client.tsx @@ -11,6 +11,9 @@ import { BarChart3, ChartColumnBig, TrendingDown, + ArrowDownToLine, + Server, + Crown, } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -145,9 +148,38 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { activityData.reduce((sum, day) => sum + day.requestCost, 0) || 0; const totalSavings = activityData.reduce((sum, day) => sum + day.discountSavings, 0) || 0; + const totalInputTokens = + activityData.reduce((sum, day) => sum + day.inputTokens, 0) || 0; + const totalCachedTokens = + activityData.reduce((sum, day) => sum + day.cachedTokens, 0) || 0; const avgCostPer1kTokens = totalTokens > 0 ? (totalCost / totalTokens) * 1000 : 0; + const { mostUsedModel, mostUsedProvider } = (() => { + const modelCostMap = new Map(); + for (const day of activityData) { + for (const m of day.modelBreakdown) { + const existing = modelCostMap.get(m.id); + if (existing) { + existing.cost += m.cost; + } else { + modelCostMap.set(m.id, { cost: m.cost, provider: m.provider }); + } + } + } + let topModel = ""; + let topProvider = ""; + let topCost = 0; + for (const [model, { cost, provider }] of Array.from(modelCostMap)) { + if (cost > topCost) { + topCost = cost; + topModel = model; + topProvider = provider; + } + } + return { mostUsedModel: topModel, mostUsedProvider: topProvider }; + })(); + const quickActions = [ { href: "api-keys", @@ -388,12 +420,12 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { accent="blue" /> 0 @@ -424,6 +456,33 @@ export function DashboardClient({ initialActivityData }: DashboardClientProps) { icon={} accent="blue" /> + } + accent="purple" + /> + } + accent="green" + /> + } + accent="purple" + />
Usage Overview - {metric === "costs" ? "Total Costs" : "Total Requests"} + {metric === "costs" + ? "Provider pricing for reference — not deducted from your balance" + : "Total Requests"} {selectedProject && ( Filtered by project: {selectedProject.name} diff --git a/apps/ui/src/components/dashboard/log-card.tsx b/apps/ui/src/components/dashboard/log-card.tsx index cc0ce980d4..a12c6ddd35 100644 --- a/apps/ui/src/components/dashboard/log-card.tsx +++ b/apps/ui/src/components/dashboard/log-card.tsx @@ -13,10 +13,12 @@ import { Info, Package, Link as LinkIcon, + ExternalLink, Plug, Sparkles, Zap, } from "lucide-react"; +import Link from "next/link"; import prettyBytes from "pretty-bytes"; import { useState } from "react"; @@ -31,7 +33,15 @@ import { import type { Log } from "@llmgateway/db"; -export function LogCard({ log }: { log: Partial }) { +export function LogCard({ + log, + orgId, + projectId, +}: { + log: Partial; + orgId?: string; + projectId?: string; +}) { // Determine if retention was enabled based on dataStorageCost // If dataStorageCost is 0 or null/undefined, retention was disabled const retentionEnabled = @@ -187,16 +197,26 @@ export function LogCard({ log }: { log: Partial }) { {formatDuration(log.duration ?? 0)}
-
- - - {log.cost - ? `$${log.cost.toFixed(6)}` - : log.cached - ? "$0" - : "$0"} - -
+ + + +
+ + + {log.cost + ? `$${log.cost.toFixed(6)}` + : log.cached + ? "$0" + : "$0"} + + +
+
+ +

Provider cost — not deducted from your balance

+
+
+
{log.source && (
@@ -214,19 +234,32 @@ export function LogCard({ log }: { log: Partial }) { {formattedTime}
- )} - Toggle details - + + {isExpanded && ( @@ -460,96 +493,99 @@ export function LogCard({ log }: { log: Partial }) {
-
+

Cost Information

-
-
Input Cost
-
- {log.inputCost ? `$${log.inputCost.toFixed(8)}` : "$0"} -
-
Output Cost
+
- {log.outputCost ? `$${log.outputCost.toFixed(8)}` : "$0"} -
- {!!log.cachedInputCost && Number(log.cachedInputCost) > 0 && ( - <> -
- Cached Input Cost -
-
{`$${Number(log.cachedInputCost).toFixed(8)}`}
- - )} -
Request Cost
-
- {log.requestCost ? `$${log.requestCost.toFixed(8)}` : "$0"} -
- {!!log.webSearchCost && Number(log.webSearchCost) > 0 && ( - <> -
- Native Web Search Cost +

+ Provider pricing — not deducted from your balance +

+
+
Input Cost
+
+ {log.inputCost ? `$${log.inputCost.toFixed(8)}` : "$0"}
-
{`$${Number(log.webSearchCost).toFixed(8)}`}
- - )} - {!!log.imageInputCost && Number(log.imageInputCost) > 0 && ( - <> -
- Image Input Cost +
Output Cost
+
+ {log.outputCost ? `$${log.outputCost.toFixed(8)}` : "$0"}
-
{`$${Number(log.imageInputCost).toFixed(8)}`}
- - )} - {!!log.imageOutputCost && Number(log.imageOutputCost) > 0 && ( - <> -
- Image Output Cost + {!!log.cachedInputCost && + Number(log.cachedInputCost) > 0 && ( + <> +
Cached Input Cost
+
{`$${Number(log.cachedInputCost).toFixed(8)}`}
+ + )} +
Request Cost
+
+ {log.requestCost + ? `$${log.requestCost.toFixed(8)}` + : "$0"}
-
{`$${Number(log.imageOutputCost).toFixed(8)}`}
- - )} -
Inference Total
-
- {log.cost ? `$${log.cost.toFixed(8)}` : "$0"} + {!!log.webSearchCost && Number(log.webSearchCost) > 0 && ( + <> +
Native Web Search Cost
+
{`$${Number(log.webSearchCost).toFixed(8)}`}
+ + )} + {!!log.imageInputCost && Number(log.imageInputCost) > 0 && ( + <> +
Image Input Cost
+
{`$${Number(log.imageInputCost).toFixed(8)}`}
+ + )} + {!!log.imageOutputCost && + Number(log.imageOutputCost) > 0 && ( + <> +
Image Output Cost
+
{`$${Number(log.imageOutputCost).toFixed(8)}`}
+ + )} +
Inference Total
+
{log.cost ? `$${log.cost.toFixed(8)}` : "$0"}
+ {log.discount && log.discount !== 1 && ( + <> +
Discount Applied
+
+ {(log.discount * 100).toFixed(0)}% off +
+ + )} + {log.pricingTier && ( + <> +
Pricing Tier
+
{log.pricingTier}
+ + )} +
- {log.discount && log.discount !== 1 && ( - <> -
- Discount Applied +
+

+ Billed to your organization +

+
+
Data Storage
+
+ {log.dataStorageCost + ? `$${Number(log.dataStorageCost).toFixed(8)}` + : "$0"}
-
- {(log.discount * 100).toFixed(0)}% off -
- - )} - {log.pricingTier && ( - <> -
Pricing Tier
-
{log.pricingTier}
- - )} -
- LLM Gateway Costs -
-
Data Storage
-
- {log.dataStorageCost - ? `$${Number(log.dataStorageCost).toFixed(8)}` - : "$0"} + {log.usedMode === "api-keys" && ( + <> +
+ API Key Fee (1%) +
+
+ {log.serviceFee + ? `$${Number(log.serviceFee).toFixed(8)}` + : log.cost + ? `$${(Number(log.cost) * 0.01).toFixed(8)}` + : "$0"} +
+ + )} +
- {log.usedMode === "api-keys" && ( - <> -
- API Key Fee (1%) -
-
- {log.serviceFee - ? `$${Number(log.serviceFee).toFixed(8)}` - : log.cost - ? `$${(Number(log.cost) * 0.01).toFixed(8)}` - : "$0"} -
- - )}
diff --git a/apps/ui/src/components/dashboard/overview.tsx b/apps/ui/src/components/dashboard/overview.tsx index 8fb6f86b98..af5fa6edfc 100644 --- a/apps/ui/src/components/dashboard/overview.tsx +++ b/apps/ui/src/components/dashboard/overview.tsx @@ -38,20 +38,23 @@ const CustomTooltip = ({

{metric === "costs" ? ( <> -

- - ${payload[0]?.value.toFixed(4)} - {" "} - Cost -

- {payload[1] && payload[1].value > 0 && ( -

- - ${payload[1].value.toFixed(4)} - {" "} - Saved + {payload.map((entry) => ( +

+ + ${entry.value.toFixed(4)}{" "} + {entry.name}

- )} + ))} ) : (

@@ -102,14 +105,18 @@ export function Overview({ // Fill in the chart data with all dates, using zero values for missing dates const chartData = dateRange.map((date) => { - if (dataByDate.has(date)) { + const day = dataByDate.get(date); + if (day) { return { date, name: format(parseISO(date), "MMM d"), - total: dataByDate.get(date)!.requestCount, - tokens: dataByDate.get(date)!.totalTokens, - cost: dataByDate.get(date)!.cost, - savings: dataByDate.get(date)!.discountSavings, + total: day.requestCount, + tokens: day.totalTokens, + cost: day.cost, + inputCost: day.inputCost, + outputCost: day.outputCost, + cachedInputCost: day.cachedInputCost ?? 0, + savings: day.discountSavings, }; } @@ -119,6 +126,9 @@ export function Overview({ total: 0, tokens: 0, cost: 0, + inputCost: 0, + outputCost: 0, + cachedInputCost: 0, savings: 0, }; }); @@ -175,17 +185,25 @@ export function Overview({ <> + diff --git a/apps/ui/src/components/dashboard/recent-activity-card.tsx b/apps/ui/src/components/dashboard/recent-activity-card.tsx index caf537b370..77e148e7da 100644 --- a/apps/ui/src/components/dashboard/recent-activity-card.tsx +++ b/apps/ui/src/components/dashboard/recent-activity-card.tsx @@ -80,7 +80,12 @@ export function RecentActivityCard({

-

${day.cost.toFixed(4)}

+

+ ${day.cost.toFixed(4)} +

+

+ inference (ref.) +

{day.discountSavings > 0 && (

-${day.discountSavings.toFixed(4)} saved diff --git a/apps/ui/src/components/models/all-models.tsx b/apps/ui/src/components/models/all-models.tsx index 1843678f62..9bf4d2b9b1 100644 --- a/apps/ui/src/components/models/all-models.tsx +++ b/apps/ui/src/components/models/all-models.tsx @@ -1009,7 +1009,7 @@ export function AllModels({ children, models, providers }: AllModelsProps) { if (discountNum > 0) { const discountedPrice = (priceNum * 1e6 * (1 - discountNum)).toFixed(2); return ( -

+
${originalPrice} diff --git a/apps/ui/src/lib/api/v1.d.ts b/apps/ui/src/lib/api/v1.d.ts index f862783a15..0468f37d52 100644 --- a/apps/ui/src/lib/api/v1.d.ts +++ b/apps/ui/src/lib/api/v1.d.ts @@ -569,6 +569,157 @@ export interface paths { patch?: never; trace?: never; }; + "/logs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Single log entry */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + log: { + id: string; + requestId: string; + createdAt: string; + updatedAt: string; + organizationId: string; + projectId: string; + apiKeyId: string; + duration: number; + requestedModel: string; + requestedProvider: string | null; + usedModel: string; + usedProvider: string; + responseSize: number; + content: string | null; + reasoningContent: string | null; + unifiedFinishReason: string | null; + finishReason: string | null; + promptTokens: string | null; + completionTokens: string | null; + totalTokens: string | null; + reasoningTokens: string | null; + messages?: unknown; + temperature: number | null; + maxTokens: number | null; + topP: number | null; + frequencyPenalty: number | null; + presencePenalty: number | null; + reasoningEffort: string | null; + responseFormat?: unknown; + tools: ({ + /** @enum {string} */ + type: "function"; + function: { + name: string; + description?: string; + parameters?: { + [key: string]: unknown; + }; + }; + } | { + /** @enum {string} */ + type: "web_search"; + user_location?: { + city?: string; + region?: string; + country?: string; + timezone?: string; + }; + /** @enum {string} */ + search_context_size?: "low" | "medium" | "high"; + max_uses?: number; + })[] | null; + toolChoice: "none" | "auto" | "required" | { + /** @enum {string} */ + type: "function"; + function: { + name: string; + }; + } | unknown; + toolResults: { + id: string; + /** @enum {string} */ + type: "function"; + function: { + name: string; + arguments: string; + }; + }[] | null; + hasError: boolean | null; + errorDetails: { + statusCode: number; + statusText: string; + responseText: string; + } | null; + cost: number | null; + inputCost: number | null; + outputCost: number | null; + requestCost: number | null; + imageInputTokens: string | null; + imageOutputTokens: string | null; + imageInputCost: number | null; + imageOutputCost: number | null; + estimatedCost: boolean | null; + canceled: boolean | null; + streamed: boolean | null; + cached: boolean | null; + customHeaders?: unknown; + /** @enum {string} */ + mode: "api-keys" | "credits" | "hybrid"; + /** @enum {string} */ + usedMode: "api-keys" | "credits"; + source: string | null; + routingMetadata?: { + availableProviders?: string[]; + selectedProvider?: string; + selectionReason?: string; + providerScores?: { + providerId: string; + score: number; + uptime?: number; + latency?: number; + price?: number; + }[]; + } | null; + }; + }; + }; + }; + /** @description Log not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/logs": { parameters: { query?: never; @@ -815,6 +966,7 @@ export interface paths { requestCount: number; inputTokens: number; outputTokens: number; + cachedTokens: number; totalTokens: number; cost: number; inputCost: number; @@ -823,6 +975,7 @@ export interface paths { dataStorageCost: number; imageInputCost: number; imageOutputCost: number; + cachedInputCost: number; errorCount: number; errorRate: number; cacheCount: number; diff --git a/apps/ui/src/types/activity.ts b/apps/ui/src/types/activity.ts index b9965ae3c4..1730a7f019 100644 --- a/apps/ui/src/types/activity.ts +++ b/apps/ui/src/types/activity.ts @@ -15,6 +15,7 @@ export interface DailyActivity { requestCount: number; inputTokens: number; outputTokens: number; + cachedTokens: number; totalTokens: number; cost: number; outputCost: number; @@ -23,6 +24,7 @@ export interface DailyActivity { dataStorageCost: number; imageInputCost: number; imageOutputCost: number; + cachedInputCost: number; errorCount: number; errorRate: number; cacheCount: number; @@ -42,6 +44,7 @@ export type ActivitT = requestCount: number; inputTokens: number; outputTokens: number; + cachedTokens: number; totalTokens: number; cost: number; inputCost: number; @@ -50,6 +53,7 @@ export type ActivitT = dataStorageCost: number; imageInputCost: number; imageOutputCost: number; + cachedInputCost: number; errorCount: number; errorRate: number; cacheCount: number;