diff --git a/src/api/generated/@tanstack/react-query.gen.ts b/src/api/generated/@tanstack/react-query.gen.ts index 0708df4f..2ca69ce6 100644 --- a/src/api/generated/@tanstack/react-query.gen.ts +++ b/src/api/generated/@tanstack/react-query.gen.ts @@ -29,6 +29,7 @@ import { v1SetWorkspaceMuxes, v1StreamSse, v1VersionCheck, + v1GetWorkspaceTokenUsage, } from "../sdk.gen"; import type { V1ListProviderEndpointsData, @@ -71,6 +72,7 @@ import type { V1SetWorkspaceMuxesData, V1SetWorkspaceMuxesError, V1SetWorkspaceMuxesResponse, + V1GetWorkspaceTokenUsageData, } from "../types.gen"; type QueryKey = [ @@ -698,3 +700,24 @@ export const v1VersionCheckOptions = (options?: OptionsLegacyParser) => { queryKey: v1VersionCheckQueryKey(options), }); }; + +export const v1GetWorkspaceTokenUsageQueryKey = ( + options: OptionsLegacyParser, +) => [createQueryKey("v1GetWorkspaceTokenUsage", options)]; + +export const v1GetWorkspaceTokenUsageOptions = ( + options: OptionsLegacyParser, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await v1GetWorkspaceTokenUsage({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: v1GetWorkspaceTokenUsageQueryKey(options), + }); +}; diff --git a/src/api/generated/sdk.gen.ts b/src/api/generated/sdk.gen.ts index fad7eedb..37fe6c10 100644 --- a/src/api/generated/sdk.gen.ts +++ b/src/api/generated/sdk.gen.ts @@ -74,6 +74,9 @@ import type { V1StreamSseResponse, V1VersionCheckError, V1VersionCheckResponse, + V1GetWorkspaceTokenUsageData, + V1GetWorkspaceTokenUsageError, + V1GetWorkspaceTokenUsageResponse, } from "./types.gen"; export const client = createClient(createConfig()); @@ -521,3 +524,20 @@ export const v1VersionCheck = ( url: "/api/v1/version", }); }; + +/** + * Get Workspace Token Usage + * Get the token usage of a workspace. + */ +export const v1GetWorkspaceTokenUsage = ( + options: OptionsLegacyParser, +) => { + return (options?.client ?? client).get< + V1GetWorkspaceTokenUsageResponse, + V1GetWorkspaceTokenUsageError, + ThrowOnError + >({ + ...options, + url: "/api/v1/workspaces/{workspace_name}/token-usage", + }); +}; diff --git a/src/api/generated/types.gen.ts b/src/api/generated/types.gen.ts index 0b14657a..c45aecb6 100644 --- a/src/api/generated/types.gen.ts +++ b/src/api/generated/types.gen.ts @@ -53,6 +53,7 @@ export type Conversation = { type: QuestionType; chat_id: string; conversation_timestamp: string; + token_usage_agg: TokenUsageAggregate | null; }; export type CreateOrRenameWorkspaceRequest = { @@ -134,6 +135,8 @@ export enum ProviderType { OPENAI = "openai", ANTHROPIC = "anthropic", VLLM = "vllm", + LLAMACPP = "llamacpp", + OLLAMA = "ollama", } /** @@ -149,6 +152,37 @@ export enum QuestionType { FIM = "fim", } +/** + * TokenUsage it's not a table, it's a model to represent the token usage. + * The data is stored in the outputs table. + */ +export type TokenUsage = { + input_tokens?: number; + output_tokens?: number; + input_cost?: number; + output_cost?: number; +}; + +/** + * Represents the tokens used. Includes the information of the tokens used by model. + * `used_tokens` are the total tokens used in the `tokens_by_model` list. + */ +export type TokenUsageAggregate = { + tokens_by_model: { + [key: string]: TokenUsageByModel; + }; + token_usage: TokenUsage; +}; + +/** + * Represents the tokens used by a model. + */ +export type TokenUsageByModel = { + provider_type: ProviderType; + model: string; + token_usage: TokenUsage; +}; + export type ValidationError = { loc: Array; msg: string; @@ -367,3 +401,13 @@ export type V1StreamSseError = unknown; export type V1VersionCheckResponse = unknown; export type V1VersionCheckError = unknown; + +export type V1GetWorkspaceTokenUsageData = { + path: { + workspace_name: string; + }; +}; + +export type V1GetWorkspaceTokenUsageResponse = TokenUsageAggregate; + +export type V1GetWorkspaceTokenUsageError = HTTPValidationError; diff --git a/src/api/openapi.json b/src/api/openapi.json index 9a9a28c1..7c11f8c8 100644 --- a/src/api/openapi.json +++ b/src/api/openapi.json @@ -8,9 +8,7 @@ "paths": { "/health": { "get": { - "tags": [ - "System" - ], + "tags": ["System"], "summary": "Health Check", "operationId": "health_check_health_get", "responses": { @@ -27,10 +25,7 @@ }, "/api/v1/provider-endpoints": { "get": { - "tags": [ - "CodeGate API", - "Providers" - ], + "tags": ["CodeGate API", "Providers"], "summary": "List Provider Endpoints", "description": "List all provider endpoints.", "operationId": "v1_list_provider_endpoints", @@ -80,10 +75,7 @@ } }, "post": { - "tags": [ - "CodeGate API", - "Providers" - ], + "tags": ["CodeGate API", "Providers"], "summary": "Add Provider Endpoint", "description": "Add a provider endpoint.", "operationId": "v1_add_provider_endpoint", @@ -123,10 +115,7 @@ }, "/api/v1/provider-endpoints/{provider_id}": { "get": { - "tags": [ - "CodeGate API", - "Providers" - ], + "tags": ["CodeGate API", "Providers"], "summary": "Get Provider Endpoint", "description": "Get a provider endpoint by ID.", "operationId": "v1_get_provider_endpoint", @@ -165,10 +154,7 @@ } }, "put": { - "tags": [ - "CodeGate API", - "Providers" - ], + "tags": ["CodeGate API", "Providers"], "summary": "Update Provider Endpoint", "description": "Update a provider endpoint by ID.", "operationId": "v1_update_provider_endpoint", @@ -217,10 +203,7 @@ } }, "delete": { - "tags": [ - "CodeGate API", - "Providers" - ], + "tags": ["CodeGate API", "Providers"], "summary": "Delete Provider Endpoint", "description": "Delete a provider endpoint by id.", "operationId": "v1_delete_provider_endpoint", @@ -259,10 +242,7 @@ }, "/api/v1/provider-endpoints/{provider_name}/models": { "get": { - "tags": [ - "CodeGate API", - "Providers" - ], + "tags": ["CodeGate API", "Providers"], "summary": "List Models By Provider", "description": "List models by provider.", "operationId": "v1_list_models_by_provider", @@ -307,10 +287,7 @@ }, "/api/v1/provider-endpoints/models": { "get": { - "tags": [ - "CodeGate API", - "Providers" - ], + "tags": ["CodeGate API", "Providers"], "summary": "List All Models For All Providers", "description": "List all models for all providers.", "operationId": "v1_list_all_models_for_all_providers", @@ -334,10 +311,7 @@ }, "/api/v1/workspaces": { "get": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "List Workspaces", "description": "List all workspaces.", "operationId": "v1_list_workspaces", @@ -355,10 +329,7 @@ } }, "post": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Create Workspace", "description": "Create a new workspace.", "operationId": "v1_create_workspace", @@ -398,10 +369,7 @@ }, "/api/v1/workspaces/active": { "get": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "List Active Workspaces", "description": "List all active workspaces.\n\nIn it's current form, this function will only return one workspace. That is,\nthe globally active workspace.", "operationId": "v1_list_active_workspaces", @@ -419,10 +387,7 @@ } }, "post": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Activate Workspace", "description": "Activate a workspace by name.", "operationId": "v1_activate_workspace", @@ -471,10 +436,7 @@ }, "/api/v1/workspaces/{workspace_name}": { "delete": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Delete Workspace", "description": "Delete a workspace by name.", "operationId": "v1_delete_workspace", @@ -513,10 +475,7 @@ }, "/api/v1/workspaces/archive": { "get": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "List Archived Workspaces", "description": "List all archived workspaces.", "operationId": "v1_list_archived_workspaces", @@ -536,10 +495,7 @@ }, "/api/v1/workspaces/archive/{workspace_name}/recover": { "post": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Recover Workspace", "description": "Recover an archived workspace by name.", "operationId": "v1_recover_workspace", @@ -573,10 +529,7 @@ }, "/api/v1/workspaces/archive/{workspace_name}": { "delete": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Hard Delete Workspace", "description": "Hard delete an archived workspace by name.", "operationId": "v1_hard_delete_workspace", @@ -615,10 +568,7 @@ }, "/api/v1/workspaces/{workspace_name}/alerts": { "get": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Get Workspace Alerts", "description": "Get alerts for a workspace.", "operationId": "v1_get_workspace_alerts", @@ -670,10 +620,7 @@ }, "/api/v1/workspaces/{workspace_name}/messages": { "get": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Get Workspace Messages", "description": "Get messages for a workspace.", "operationId": "v1_get_workspace_messages", @@ -718,10 +665,7 @@ }, "/api/v1/workspaces/{workspace_name}/custom-instructions": { "get": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Get Workspace Custom Instructions", "description": "Get the custom instructions of a workspace.", "operationId": "v1_get_workspace_custom_instructions", @@ -760,10 +704,7 @@ } }, "put": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Set Workspace Custom Instructions", "operationId": "v1_set_workspace_custom_instructions", "parameters": [ @@ -804,10 +745,7 @@ } }, "delete": { - "tags": [ - "CodeGate API", - "Workspaces" - ], + "tags": ["CodeGate API", "Workspaces"], "summary": "Delete Workspace Custom Instructions", "operationId": "v1_delete_workspace_custom_instructions", "parameters": [ @@ -840,11 +778,7 @@ }, "/api/v1/workspaces/{workspace_name}/muxes": { "get": { - "tags": [ - "CodeGate API", - "Workspaces", - "Muxes" - ], + "tags": ["CodeGate API", "Workspaces", "Muxes"], "summary": "Get Workspace Muxes", "description": "Get the mux rules of a workspace.\n\nThe list is ordered in order of priority. That is, the first rule in the list\nhas the highest priority.", "operationId": "v1_get_workspace_muxes", @@ -887,11 +821,7 @@ } }, "put": { - "tags": [ - "CodeGate API", - "Workspaces", - "Muxes" - ], + "tags": ["CodeGate API", "Workspaces", "Muxes"], "summary": "Set Workspace Muxes", "description": "Set the mux rules of a workspace.", "operationId": "v1_set_workspace_muxes", @@ -939,10 +869,7 @@ }, "/api/v1/alerts_notification": { "get": { - "tags": [ - "CodeGate API", - "Dashboard" - ], + "tags": ["CodeGate API", "Dashboard"], "summary": "Stream Sse", "description": "Send alerts event", "operationId": "v1_stream_sse", @@ -960,10 +887,7 @@ }, "/api/v1/version": { "get": { - "tags": [ - "CodeGate API", - "Dashboard" - ], + "tags": ["CodeGate API", "Dashboard"], "summary": "Version Check", "operationId": "v1_version_check", "responses": { @@ -977,6 +901,47 @@ } } } + }, + "/api/v1/workspaces/{workspace_name}/token-usage": { + "get": { + "tags": ["CodeGate API", "Workspaces", "Token Usage"], + "summary": "Get Workspace Token Usage", + "description": "Get the token usage of a workspace.", + "operationId": "v1_get_workspace_token_usage", + "parameters": [ + { + "name": "workspace_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workspace Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenUsageAggregate" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -989,9 +954,7 @@ } }, "type": "object", - "required": [ - "name" - ], + "required": ["name"], "title": "ActivateWorkspaceRequest" }, "ActiveWorkspace": { @@ -1009,11 +972,7 @@ } }, "type": "object", - "required": [ - "name", - "is_active", - "last_updated" - ], + "required": ["name", "is_active", "last_updated"], "title": "ActiveWorkspace" }, "AlertConversation": { @@ -1100,11 +1059,7 @@ } }, "type": "object", - "required": [ - "message", - "timestamp", - "message_id" - ], + "required": ["message", "timestamp", "message_id"], "title": "ChatMessage", "description": "Represents a chat message." }, @@ -1145,11 +1100,7 @@ } }, "type": "object", - "required": [ - "code", - "language", - "filepath" - ], + "required": ["code", "language", "filepath"], "title": "CodeSnippet" }, "Conversation": { @@ -1183,6 +1134,16 @@ "type": "string", "format": "date-time", "title": "Conversation Timestamp" + }, + "token_usage_agg": { + "anyOf": [ + { + "$ref": "#/components/schemas/TokenUsageAggregate" + }, + { + "type": "null" + } + ] } }, "type": "object", @@ -1191,7 +1152,8 @@ "provider", "type", "chat_id", - "conversation_timestamp" + "conversation_timestamp", + "token_usage_agg" ], "title": "Conversation", "description": "Represents a conversation." @@ -1215,9 +1177,7 @@ } }, "type": "object", - "required": [ - "name" - ], + "required": ["name"], "title": "CreateOrRenameWorkspaceRequest" }, "CustomInstructions": { @@ -1228,9 +1188,7 @@ } }, "type": "object", - "required": [ - "prompt" - ], + "required": ["prompt"], "title": "CustomInstructions" }, "HTTPValidationError": { @@ -1257,9 +1215,7 @@ } }, "type": "object", - "required": [ - "workspaces" - ], + "required": ["workspaces"], "title": "ListActiveWorkspacesResponse" }, "ListWorkspacesResponse": { @@ -1273,9 +1229,7 @@ } }, "type": "object", - "required": [ - "workspaces" - ], + "required": ["workspaces"], "title": "ListWorkspacesResponse" }, "ModelByProvider": { @@ -1290,19 +1244,13 @@ } }, "type": "object", - "required": [ - "name", - "provider" - ], + "required": ["name", "provider"], "title": "ModelByProvider", "description": "Represents a model supported by a provider.\n\nNote that these are auto-discovered by the provider." }, "MuxMatcherType": { "type": "string", - "enum": [ - "file_regex", - "catch_all" - ], + "enum": ["file_regex", "catch_all"], "title": "MuxMatcherType", "description": "Represents the different types of matchers we support." }, @@ -1332,22 +1280,13 @@ } }, "type": "object", - "required": [ - "provider", - "model", - "matcher_type", - "matcher" - ], + "required": ["provider", "model", "matcher_type", "matcher"], "title": "MuxRule", "description": "Represents a mux rule for a provider." }, "ProviderAuthType": { "type": "string", - "enum": [ - "none", - "passthrough", - "api_key" - ], + "enum": ["none", "passthrough", "api_key"], "title": "ProviderAuthType", "description": "Represents the different types of auth we support for providers." }, @@ -1378,23 +1317,13 @@ } }, "type": "object", - "required": [ - "id", - "name", - "provider_type", - "endpoint", - "auth_type" - ], + "required": ["id", "name", "provider_type", "endpoint", "auth_type"], "title": "ProviderEndpoint", "description": "Represents a provider's endpoint configuration. This\nallows us to persist the configuration for each provider,\nso we can use this for muxing messages." }, "ProviderType": { "type": "string", - "enum": [ - "openai", - "anthropic", - "vllm" - ], + "enum": ["openai", "anthropic", "vllm", "llamacpp", "ollama"], "title": "ProviderType", "description": "Represents the different types of providers we support." }, @@ -1415,21 +1344,78 @@ } }, "type": "object", - "required": [ - "question", - "answer" - ], + "required": ["question", "answer"], "title": "QuestionAnswer", "description": "Represents a question and answer pair." }, "QuestionType": { "type": "string", - "enum": [ - "chat", - "fim" - ], + "enum": ["chat", "fim"], "title": "QuestionType" }, + "TokenUsage": { + "properties": { + "input_tokens": { + "type": "integer", + "title": "Input Tokens", + "default": 0 + }, + "output_tokens": { + "type": "integer", + "title": "Output Tokens", + "default": 0 + }, + "input_cost": { + "type": "number", + "title": "Input Cost", + "default": 0 + }, + "output_cost": { + "type": "number", + "title": "Output Cost", + "default": 0 + } + }, + "type": "object", + "title": "TokenUsage", + "description": "TokenUsage it's not a table, it's a model to represent the token usage.\nThe data is stored in the outputs table." + }, + "TokenUsageAggregate": { + "properties": { + "tokens_by_model": { + "additionalProperties": { + "$ref": "#/components/schemas/TokenUsageByModel" + }, + "type": "object", + "title": "Tokens By Model" + }, + "token_usage": { + "$ref": "#/components/schemas/TokenUsage" + } + }, + "type": "object", + "required": ["tokens_by_model", "token_usage"], + "title": "TokenUsageAggregate", + "description": "Represents the tokens used. Includes the information of the tokens used by model.\n`used_tokens` are the total tokens used in the `tokens_by_model` list." + }, + "TokenUsageByModel": { + "properties": { + "provider_type": { + "$ref": "#/components/schemas/ProviderType" + }, + "model": { + "type": "string", + "title": "Model" + }, + "token_usage": { + "$ref": "#/components/schemas/TokenUsage" + } + }, + "type": "object", + "required": ["provider_type", "model", "token_usage"], + "title": "TokenUsageByModel", + "description": "Represents the tokens used by a model." + }, "ValidationError": { "properties": { "loc": { @@ -1456,11 +1442,7 @@ } }, "type": "object", - "required": [ - "loc", - "msg", - "type" - ], + "required": ["loc", "msg", "type"], "title": "ValidationError" }, "Workspace": { @@ -1475,10 +1457,7 @@ } }, "type": "object", - "required": [ - "name", - "is_active" - ], + "required": ["name", "is_active"], "title": "Workspace" } } diff --git a/src/features/alerts/components/__tests__/table-alerts.test.tsx b/src/features/alerts/components/__tests__/table-alerts.test.tsx new file mode 100644 index 00000000..a2dce3f5 --- /dev/null +++ b/src/features/alerts/components/__tests__/table-alerts.test.tsx @@ -0,0 +1,133 @@ +import {} from "vitest"; +import { TableAlerts } from "../table-alerts"; +import { render, screen, waitFor, within } from "@/lib/test-utils"; +import { server } from "@/mocks/msw/node"; +import { http, HttpResponse } from "msw"; +import { + AlertConversation, + ProviderType, + QuestionType, + TokenUsageAggregate, +} from "@/api/generated"; + +vi.mock("lucide-react", async () => { + const original = + await vi.importActual("lucide-react"); + return { + ...original, + ArrowDown: () =>
, + ArrowUp: () =>
, + }; +}); + +const TOKEN_USAGE_AGG = { + tokens_by_model: { + "claude-3-5-sonnet-latest": { + provider_type: ProviderType.ANTHROPIC, + model: "claude-3-5-sonnet-latest", + token_usage: { + input_tokens: 1183, + output_tokens: 433, + input_cost: 0.003549, + output_cost: 0.006495, + }, + }, + }, + token_usage: { + input_tokens: 1183, + output_tokens: 433, + input_cost: 0.003549, + output_cost: 0.006495, + }, +} as const satisfies TokenUsageAggregate; + +const makeMockAlert = ({ + token_usage_agg, +}: { + token_usage_agg: TokenUsageAggregate | null; +}) => { + return { + conversation: { + question_answers: [ + { + question: { + message: "foo", + timestamp: "2025-01-28T14:32:57.836445Z", + message_id: "cfdf1acd-999c-430b-bb57-4c4db6cec6c9", + }, + answer: { + message: "bar", + timestamp: "2025-01-28T14:32:59.107793Z", + message_id: "c2b88968-06b3-485d-a42b-7b54f973eef9", + }, + }, + ], + provider: "anthropic", + type: QuestionType.CHAT, + chat_id: "cfdf1acd-999c-430b-bb57-4c4db6cec6c9", + conversation_timestamp: "2025-01-28T14:32:57.836445Z", + token_usage_agg, + }, + alert_id: "2379b08b-1e2b-4d6b-b425-e58a0b4fe7bc", + code_snippet: null, + trigger_string: "foo", + trigger_type: "codegate-secrets", + trigger_category: "critical", + timestamp: "2025-01-28T14:32:57.599032Z", + } as const satisfies AlertConversation; +}; + +const INPUT_TOKENS = + TOKEN_USAGE_AGG.tokens_by_model[ + "claude-3-5-sonnet-latest" + ].token_usage.input_tokens.toString(); + +const OUTPUT_TOKENS = + TOKEN_USAGE_AGG.tokens_by_model[ + "claude-3-5-sonnet-latest" + ].token_usage.output_tokens.toString(); + +test("renders token usage cell correctly", async () => { + server.use( + http.get("*/workspaces/:name/alerts", () => { + return HttpResponse.json([ + makeMockAlert({ token_usage_agg: TOKEN_USAGE_AGG }), + ]); + }), + ); + + const { getByRole, getByTestId } = render(); + + await waitFor(() => { + expect( + within(screen.getByTestId("alerts-table")).getAllByRole("row"), + ).toHaveLength(2); + }); + + expect(getByTestId("icon-arrow-up")).toBeVisible(); + expect(getByTestId("icon-arrow-down")).toBeVisible(); + + expect( + getByRole("gridcell", { + name: `${INPUT_TOKENS} ${OUTPUT_TOKENS}`, + }), + ).toBeVisible(); +}); + +test("renders N/A when token usage is missing", async () => { + server.use( + http.get("*/workspaces/:name/alerts", () => { + return HttpResponse.json([makeMockAlert({ token_usage_agg: null })]); + }), + ); + + const { getByText } = render(); + + await waitFor(() => { + expect( + within(screen.getByTestId("alerts-table")).getAllByRole("row"), + ).toHaveLength(2); + }); + + expect(getByText("N/A")).toBeVisible(); +}); diff --git a/src/features/alerts/components/table-alert-token-usage.tsx b/src/features/alerts/components/table-alert-token-usage.tsx new file mode 100644 index 00000000..dbd05f49 --- /dev/null +++ b/src/features/alerts/components/table-alert-token-usage.tsx @@ -0,0 +1,144 @@ +import { TokenUsage, TokenUsageAggregate } from "@/api/generated"; +import { formatCurrency } from "@/lib/currency"; +import { TextLinkButton, Tooltip, TooltipTrigger } from "@stacklok/ui-kit"; +import { ArrowDown, ArrowUp } from "lucide-react"; + +function Icons({ + input_tokens = 0, + output_tokens = 0, +}: { + input_tokens: number | null; + output_tokens: number | null; +}) { + return ( +
+
+ + {input_tokens} +
+
+ + {output_tokens} +
+
+ ); +} + +function validateUsage(usage: TokenUsage | null): usage is { + input_tokens: number; + output_tokens: number; + input_cost: number; + output_cost: number; +} { + return Boolean( + typeof usage?.input_tokens !== "undefined" && + typeof usage.input_cost !== "undefined" && + typeof usage.output_tokens !== "undefined" && + typeof usage.output_cost !== "undefined", + ); +} + +function UsageIcon({ + iconType: iconType, + ...props +}: { + iconType: "input" | "output"; + className?: string; +}) { + switch (iconType) { + case "input": + return ; + case "output": + return ; + default: + iconType satisfies never; + } +} + +function UsageRow({ + cost, + tokens, + type, +}: { + type: "input" | "output"; + tokens: number; + cost: number; +}) { + return ( +
  • + + +
    {tokens}
    + +
    + {formatCurrency(cost, { currency: "USD" })} +
    +
  • + ); +} + +function UsageRows({ + input_cost, + input_tokens, + output_cost, + output_tokens, +}: { + input_tokens: number; + output_tokens: number; + input_cost: number; + output_cost: number; +}) { + return ( + <> + + + + ); +} + +function TokenUsageByProviders({ tokens_by_model }: TokenUsageAggregate) { + return Object.values(tokens_by_model).map( + ({ provider_type, token_usage: modelTokenUsage, model }) => { + if (!validateUsage(modelTokenUsage)) return null; + + return ( +
    +
    + Model: {model} +
    +
    + Provider: {provider_type} +
    +
      + +
    +
    + ); + }, + ); +} + +export function TableAlertTokenUsage({ + usage, +}: { + usage: TokenUsageAggregate | null; +}) { + if (!usage) return "N/A"; + + return ( + + + + + + + + + ); +} diff --git a/src/components/AlertsTable.tsx b/src/features/alerts/components/table-alerts.tsx similarity index 80% rename from src/components/AlertsTable.tsx rename to src/features/alerts/components/table-alerts.tsx index 6b0d7ac0..609bce6a 100644 --- a/src/components/AlertsTable.tsx +++ b/src/features/alerts/components/table-alerts.tsx @@ -28,6 +28,7 @@ import { useCallback } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useFilteredAlerts } from "@/hooks/useAlertsData"; import { useClientSidePagination } from "@/hooks/useClientSidePagination"; +import { TableAlertTokenUsage } from "./table-alert-token-usage"; const getTitle = (alert: AlertConversation) => { const prompt = alert.conversation; @@ -70,7 +71,6 @@ function IssueDetectedCellContent({ alert }: { alert: AlertConversation }) { return ( <> - Blocked malicious package ); @@ -79,7 +79,7 @@ function IssueDetectedCellContent({ alert }: { alert: AlertConversation }) { } } -export function AlertsTable() { +export function TableAlerts() { const { isMaliciousFilterActive, setIsMaliciousFilterActive, @@ -185,33 +185,41 @@ export function AlertsTable() { Type Event Issue Detected + Token usage - {dataView.map((alert) => ( - - navigate(`/prompt/${alert.conversation.chat_id}`) - } - > - - {formatDistanceToNow(new Date(alert.timestamp), { - addSuffix: true, - })} - - - - - {getTitle(alert)} - -
    - -
    -
    -
    - ))} + {dataView.map((alert) => { + return ( + + navigate(`/prompt/${alert.conversation.chat_id}`) + } + > + + {formatDistanceToNow(new Date(alert.timestamp), { + addSuffix: true, + })} + + + + + {getTitle(alert)} + +
    + +
    +
    + + + +
    + ); + })}
    diff --git a/src/features/alerts/lib/get-alert-token-usage.ts b/src/features/alerts/lib/get-alert-token-usage.ts new file mode 100644 index 00000000..837ff5ef --- /dev/null +++ b/src/features/alerts/lib/get-alert-token-usage.ts @@ -0,0 +1,7 @@ +import { AlertConversation, TokenUsageAggregate } from "@/api/generated"; + +export function getAlertTokenUsage( + alert: AlertConversation, +): TokenUsageAggregate | null { + return alert.conversation.token_usage_agg; +} diff --git a/src/features/alerts/mocks/alert-malicious.mock.ts b/src/features/alerts/mocks/alert-malicious.mock.ts index 0605ff64..e409a637 100644 --- a/src/features/alerts/mocks/alert-malicious.mock.ts +++ b/src/features/alerts/mocks/alert-malicious.mock.ts @@ -22,6 +22,7 @@ export const ALERT_MALICIOUS = { type: QuestionType.CHAT, chat_id: "bf92bf3c-fcec-4064-ad02-c792026c3555", conversation_timestamp: "2025-01-14T16:29:49.602403Z", + token_usage_agg: null, }, alert_id: "bf92bf3c-fcec-4064-ad02-c792026c3555", code_snippet: null, diff --git a/src/features/alerts/mocks/alert-secret.mock.ts b/src/features/alerts/mocks/alert-secret.mock.ts index 287d8f99..158f5afc 100644 --- a/src/features/alerts/mocks/alert-secret.mock.ts +++ b/src/features/alerts/mocks/alert-secret.mock.ts @@ -20,6 +20,7 @@ export const ALERT_SECRET = { type: QuestionType.CHAT, chat_id: "11ab8b11-0338-4fdb-b329-2184d3e71a14", conversation_timestamp: "2025-01-13T17:15:06.942856Z", + token_usage_agg: null, }, alert_id: "11ab8b11-0338-4fdb-b329-2184d3e71a14", code_snippet: null, diff --git a/src/lib/__tests__/currency.test.ts b/src/lib/__tests__/currency.test.ts new file mode 100644 index 00000000..1ff4e2a2 --- /dev/null +++ b/src/lib/__tests__/currency.test.ts @@ -0,0 +1,30 @@ +import { convertCurrencyToMinor, convertCurrencyFromMinor } from "../currency"; + +it("convertCurrencyToMinor", () => { + expect(convertCurrencyToMinor(1, "AED")).toBe(100); + expect(convertCurrencyToMinor(1, "JOD")).toBe(1000); + + expect(convertCurrencyToMinor(1.0, "AED")).toBe(100); + expect(convertCurrencyToMinor(1.0, "JOD")).toBe(1000); + + expect(convertCurrencyToMinor(1.11, "AED")).toBe(111); + expect(convertCurrencyToMinor(1.11, "JOD")).toBe(1110); + + expect(convertCurrencyToMinor(1.1111, "AED")).toBe(111); + expect(convertCurrencyToMinor(1.1111, "JOD")).toBe(1111); + + expect(convertCurrencyToMinor(10.0, "AED")).toBe(1000); + expect(convertCurrencyToMinor(1.0, "JOD")).toBe(1000); + + expect(convertCurrencyToMinor(420.69, "AED")).toBe(42069); + expect(convertCurrencyToMinor(42.069, "JOD")).toBe(42069); +}); + +it("convertCurrencyFromMinor", () => { + expect(convertCurrencyFromMinor(100, "AED")).toBe(1); + expect(convertCurrencyFromMinor(100, "JOD")).toBe(0.1); + expect(convertCurrencyFromMinor(42069, "AED")).toBe(420.69); + expect(convertCurrencyFromMinor(42069, "JOD")).toBe(42.069); + expect(convertCurrencyFromMinor(42690, "AED")).toBe(426.9); + expect(convertCurrencyFromMinor(42690, "JOD")).toBe(42.69); +}); diff --git a/src/lib/currency.ts b/src/lib/currency.ts new file mode 100644 index 00000000..70a83d24 --- /dev/null +++ b/src/lib/currency.ts @@ -0,0 +1,79 @@ +type Currency = ("GBP" | "USD" | "EUR" | "AED" | "JOD") & string; + +type FormatCurrencyOptions = { + currency: Currency; + from_minor?: boolean; + region?: string | string[] | undefined; + to_minor?: boolean; +}; + +export const getCurrencyFormatOptions = (currency: Currency) => { + return new Intl.NumberFormat(undefined, { + currency: currency, + currencyDisplay: "code", + style: "currency", + }).resolvedOptions(); +}; + +export function formatCurrency( + number: number, + { + currency = "GBP", + from_minor, + region = "en-US", + to_minor, + }: FormatCurrencyOptions, +): string { + if (from_minor) { + return new Intl.NumberFormat( + region, + getCurrencyFormatOptions(currency), + ).format(convertCurrencyFromMinor(number, currency)); + } + + if (to_minor) { + return new Intl.NumberFormat( + region, + getCurrencyFormatOptions(currency), + ).format(convertCurrencyToMinor(number, currency)); + } + + return new Intl.NumberFormat( + region, + getCurrencyFormatOptions(currency), + ).format(number); +} + +const getDigits = (currency: Currency): number => { + const digits = new Intl.NumberFormat(undefined, { + currency, + style: "currency", + }).resolvedOptions().maximumFractionDigits; + if (digits === undefined) + throw Error( + `[currency/getDigits] Unable to get digits for currency ${currency}`, + ); + + return digits; +}; + +export function convertCurrencyToMinor( + amount: number, + currency: Currency, +): number { + return Math.round(amount * 10 ** getDigits(currency)); +} + +export function convertCurrencyFromMinor( + amount: number, + currency: Currency, +): number { + return amount / 10 ** getDigits(currency); +} + +export function formatCurrencyWithTrailingZeroes( + number: number, + currency: Currency, +) { + return number.toFixed(getDigits(currency)); +} diff --git a/src/routes/__tests__/route-dashboard.test.tsx b/src/routes/__tests__/route-dashboard.test.tsx index 0676cb20..a2faf6bb 100644 --- a/src/routes/__tests__/route-dashboard.test.tsx +++ b/src/routes/__tests__/route-dashboard.test.tsx @@ -162,6 +162,12 @@ describe("Dashboard", () => { }), ).toBeVisible(); + expect( + screen.getByRole("columnheader", { + name: /token usage/i, + }), + ).toBeVisible(); + expect( screen.getByRole("switch", { name: /malicious packages/i, diff --git a/src/routes/route-dashboard.tsx b/src/routes/route-dashboard.tsx index 766d81dc..b7e4dbd2 100644 --- a/src/routes/route-dashboard.tsx +++ b/src/routes/route-dashboard.tsx @@ -10,7 +10,7 @@ import { useMaliciousPackagesChartData, } from "@/hooks/useAlertsData"; import { useAlertSearch } from "@/hooks/useAlertSearch"; -import { AlertsTable } from "@/components/AlertsTable"; +import { TableAlerts } from "@/features/alerts/components/table-alerts"; export function RouteDashboard() { const [searchParams] = useSearchParams(); @@ -43,7 +43,7 @@ export function RouteDashboard() { - +
    ); }