From 553824da45b3f3391930f157ffbfd1f3b6c227ac Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 28 Nov 2025 00:58:30 +0000 Subject: [PATCH 1/9] feat: v1 of requestlogsrow cached tokens --- .../RequestLogsRow/RequestLogsRow.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index 4bdd2013a8f50..d4c8b84fbc2c3 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -34,6 +34,38 @@ export const RequestLogsRow: FC = ({ interception }) => { (acc, tokenUsage) => acc + tokenUsage.output_tokens, 0, ); + + const KEY_ANTHROPIC_READ = "cache_read_input"; + const KEY_ANTHROPIC_WRITTEN = "cache_creation_input"; + + const KEY_OPENAI_READ = "prompt_cached"; + + // These are an unstructured metadata field of "Record", + // so we need to check if they're numbers and if not, return 0. + const cachedReadTokens = interception.token_usages.reduce( + (acc, tokenUsage) => + acc + + (interception.provider === "anthropic" + ? typeof tokenUsage.metadata?.[KEY_ANTHROPIC_READ] === "number" + ? tokenUsage.metadata?.[KEY_ANTHROPIC_READ] + : 0 + : typeof tokenUsage.metadata?.[KEY_OPENAI_READ] === "number" + ? tokenUsage.metadata?.[KEY_OPENAI_READ] + : 0), + 0, + ); + const cachedWrittenTokens = interception.token_usages.reduce( + (acc, tokenUsage) => + acc + + (interception.provider === "anthropic" + ? typeof tokenUsage.metadata?.[KEY_ANTHROPIC_WRITTEN] === "number" + ? tokenUsage.metadata?.[KEY_ANTHROPIC_WRITTEN] + : 0 + : // OpenAI doesn't have a cached written tokens field, so we return 0. + 0), + 0, + ); + const toolCalls = interception.tool_usages.length; const duration = interception.ended_at && @@ -154,6 +186,12 @@ export const RequestLogsRow: FC = ({ interception }) => {
Output Tokens:
{outputTokens}
+
Cached Read Tokens:
+
{cachedReadTokens}
+ +
Cached Written Tokens:
+
{cachedWrittenTokens}
+
Tool Calls:
{interception.tool_usages.length} From be38c1002602f081e771c35c8592069915cda05c Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 28 Nov 2025 04:16:45 +0000 Subject: [PATCH 2/9] feat: implement `magicMetadataMerge()` --- .../RequestLogsRow/RequestLogsRow.tsx | 89 +++++++++++-------- 1 file changed, 54 insertions(+), 35 deletions(-) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index d4c8b84fbc2c3..e064f5fd46f3f 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -7,6 +7,12 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; +import every from "lodash/every"; +import keys from "lodash/keys"; +import mapValues from "lodash/mapValues"; +import some from "lodash/some"; +import sum from "lodash/sum"; +import uniq from "lodash/uniq"; import { ArrowDownIcon, ArrowUpIcon, @@ -21,6 +27,43 @@ type RequestLogsRowProps = { interception: AIBridgeInterception; }; +/** + * This function merges multiple objects with the same keys into a single object. + * It's super unconventional, but it's only a temporary workaround until we + * structure our metadata field for rendering in the UI. + * @param objects - The objects to merge. + * @returns The merged object. + */ +const magicMetadataMerge = (...objects: Record[]): unknown => { + // Filter out empty objects + const nonEmptyObjects = objects.filter((obj) => keys(obj).length > 0); + + // If all objects were empty, return null + if (nonEmptyObjects.length === 0) return null; + + // Check if all objects have the same keys + const keySets = nonEmptyObjects.map((obj) => keys(obj).sort().join(",")); + // If the keys are different, just instantly return the objects + if (uniq(keySets).length > 1) return nonEmptyObjects; + + // Group the objects by key + const grouped = mapValues(nonEmptyObjects[0], (_, key) => + nonEmptyObjects.map((obj) => obj[key]), + ); + + // Map the grouped values to a new object + const result = mapValues(grouped, (values: unknown[]) => { + const allNumeric = every(values, (v: unknown) => typeof v === "number"); + const allSame = uniq(values).length === 1; + + if (allNumeric) return sum(values); + if (allSame) return values[0]; + return null; // Mark conflict + }); + + return some(result, (v: unknown) => v === null) ? nonEmptyObjects : result; +}; + export const RequestLogsRow: FC = ({ interception }) => { const [isOpen, setIsOpen] = useState(false); @@ -35,35 +78,8 @@ export const RequestLogsRow: FC = ({ interception }) => { 0, ); - const KEY_ANTHROPIC_READ = "cache_read_input"; - const KEY_ANTHROPIC_WRITTEN = "cache_creation_input"; - - const KEY_OPENAI_READ = "prompt_cached"; - - // These are an unstructured metadata field of "Record", - // so we need to check if they're numbers and if not, return 0. - const cachedReadTokens = interception.token_usages.reduce( - (acc, tokenUsage) => - acc + - (interception.provider === "anthropic" - ? typeof tokenUsage.metadata?.[KEY_ANTHROPIC_READ] === "number" - ? tokenUsage.metadata?.[KEY_ANTHROPIC_READ] - : 0 - : typeof tokenUsage.metadata?.[KEY_OPENAI_READ] === "number" - ? tokenUsage.metadata?.[KEY_OPENAI_READ] - : 0), - 0, - ); - const cachedWrittenTokens = interception.token_usages.reduce( - (acc, tokenUsage) => - acc + - (interception.provider === "anthropic" - ? typeof tokenUsage.metadata?.[KEY_ANTHROPIC_WRITTEN] === "number" - ? tokenUsage.metadata?.[KEY_ANTHROPIC_WRITTEN] - : 0 - : // OpenAI doesn't have a cached written tokens field, so we return 0. - 0), - 0, + const tokenUsagesMetadata = magicMetadataMerge( + ...interception.token_usages.map((tokenUsage) => tokenUsage.metadata), ); const toolCalls = interception.tool_usages.length; @@ -186,12 +202,6 @@ export const RequestLogsRow: FC = ({ interception }) => {
Output Tokens:
{outputTokens}
-
Cached Read Tokens:
-
{cachedReadTokens}
- -
Cached Written Tokens:
-
{cachedWrittenTokens}
-
Tool Calls:
{interception.tool_usages.length} @@ -246,6 +256,15 @@ export const RequestLogsRow: FC = ({ interception }) => { )} + + {tokenUsagesMetadata !== null && ( +
+
Metadata
+
+
{JSON.stringify(tokenUsagesMetadata, null, 2)}
+
+
+ )} From 362b29edaea09a3fcce792043a36f17755c125db Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 28 Nov 2025 04:17:39 +0000 Subject: [PATCH 3/9] chore: add `TODO` comment --- .../RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index e064f5fd46f3f..8260156de7500 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -35,6 +35,8 @@ type RequestLogsRowProps = { * @returns The merged object. */ const magicMetadataMerge = (...objects: Record[]): unknown => { + // TODO: Where possible, use native JS functions instead of lodash functions + // Filter out empty objects const nonEmptyObjects = objects.filter((obj) => keys(obj).length > 0); From e6f320f11268067fbdf41357abb1379f60c3ef7d Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 28 Nov 2025 04:25:15 +0000 Subject: [PATCH 4/9] fix: convert to `function` and use correct type --- .../RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index 8260156de7500..67fb41fdad72c 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -34,7 +34,9 @@ type RequestLogsRowProps = { * @param objects - The objects to merge. * @returns The merged object. */ -const magicMetadataMerge = (...objects: Record[]): unknown => { +function tokenUsageMetadataMerge( + ...objects: Array +): unknown { // TODO: Where possible, use native JS functions instead of lodash functions // Filter out empty objects @@ -64,7 +66,7 @@ const magicMetadataMerge = (...objects: Record[]): unknown => { }); return some(result, (v: unknown) => v === null) ? nonEmptyObjects : result; -}; +} export const RequestLogsRow: FC = ({ interception }) => { const [isOpen, setIsOpen] = useState(false); @@ -80,7 +82,7 @@ export const RequestLogsRow: FC = ({ interception }) => { 0, ); - const tokenUsagesMetadata = magicMetadataMerge( + const tokenUsagesMetadata = tokenUsageMetadataMerge( ...interception.token_usages.map((tokenUsage) => tokenUsage.metadata), ); From 6dc7f70c5626ea7ea4a40df7de3fe3c2ceb0ccab Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 28 Nov 2025 04:27:45 +0000 Subject: [PATCH 5/9] chore: remove unnecessary lodash --- .../RequestLogsRow/RequestLogsRow.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index 67fb41fdad72c..453d0008395b0 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -7,12 +7,6 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import every from "lodash/every"; -import keys from "lodash/keys"; -import mapValues from "lodash/mapValues"; -import some from "lodash/some"; -import sum from "lodash/sum"; -import uniq from "lodash/uniq"; import { ArrowDownIcon, ArrowUpIcon, @@ -37,35 +31,43 @@ type RequestLogsRowProps = { function tokenUsageMetadataMerge( ...objects: Array ): unknown { - // TODO: Where possible, use native JS functions instead of lodash functions - // Filter out empty objects - const nonEmptyObjects = objects.filter((obj) => keys(obj).length > 0); + const nonEmptyObjects = objects.filter((obj) => Object.keys(obj).length > 0); // If all objects were empty, return null if (nonEmptyObjects.length === 0) return null; // Check if all objects have the same keys - const keySets = nonEmptyObjects.map((obj) => keys(obj).sort().join(",")); + const keySets = nonEmptyObjects.map((obj) => + Object.keys(obj).sort().join(","), + ); // If the keys are different, just instantly return the objects - if (uniq(keySets).length > 1) return nonEmptyObjects; + if (new Set(keySets).size > 1) return nonEmptyObjects; // Group the objects by key - const grouped = mapValues(nonEmptyObjects[0], (_, key) => - nonEmptyObjects.map((obj) => obj[key]), + const grouped = Object.fromEntries( + Object.keys(nonEmptyObjects[0]).map((key) => [ + key, + nonEmptyObjects.map((obj) => obj[key]), + ]), ); // Map the grouped values to a new object - const result = mapValues(grouped, (values: unknown[]) => { - const allNumeric = every(values, (v: unknown) => typeof v === "number"); - const allSame = uniq(values).length === 1; + const result = Object.fromEntries( + Object.entries(grouped).map(([key, values]: [string, unknown[]]) => { + const allNumeric = values.every((v: unknown) => typeof v === "number"); + const allSame = new Set(values).size === 1; - if (allNumeric) return sum(values); - if (allSame) return values[0]; - return null; // Mark conflict - }); + if (allNumeric) + return [key, values.reduce((acc, v) => acc + (v as number), 0)]; + if (allSame) return [key, values[0]]; + return [key, null]; // Mark conflict + }), + ); - return some(result, (v: unknown) => v === null) ? nonEmptyObjects : result; + return Object.values(result).some((v: unknown) => v === null) + ? nonEmptyObjects + : result; } export const RequestLogsRow: FC = ({ interception }) => { From 374a899d29e7db671df9deeb51e20797d97bfc7f Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 28 Nov 2025 04:30:00 +0000 Subject: [PATCH 6/9] fix: update heading in result --- .../RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index 453d0008395b0..a743d6c5a6375 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -265,7 +265,7 @@ export const RequestLogsRow: FC = ({ interception }) => { {tokenUsagesMetadata !== null && (
-
Metadata
+
Token Usage Metadata
{JSON.stringify(tokenUsagesMetadata, null, 2)}
From d73ed239ccc2d4896d898433e4785b80cbecd90b Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 12 Dec 2025 09:47:58 +0000 Subject: [PATCH 7/9] feat: address feedback for merging fields --- .../RequestLogsRow/RequestLogsRow.tsx | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index a743d6c5a6375..6b7c328ee238a 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -31,40 +31,60 @@ type RequestLogsRowProps = { function tokenUsageMetadataMerge( ...objects: Array ): unknown { - // Filter out empty objects const nonEmptyObjects = objects.filter((obj) => Object.keys(obj).length > 0); + if (nonEmptyObjects.length === 0) { + return null; + } - // If all objects were empty, return null - if (nonEmptyObjects.length === 0) return null; - - // Check if all objects have the same keys - const keySets = nonEmptyObjects.map((obj) => - Object.keys(obj).sort().join(","), - ); - // If the keys are different, just instantly return the objects - if (new Set(keySets).size > 1) return nonEmptyObjects; - - // Group the objects by key - const grouped = Object.fromEntries( - Object.keys(nonEmptyObjects[0]).map((key) => [ - key, - nonEmptyObjects.map((obj) => obj[key]), - ]), + const allKeys = new Set(nonEmptyObjects.flatMap((obj) => Object.keys(obj))); + const commonKeys = Array.from(allKeys).filter((key) => + nonEmptyObjects.every((obj) => key in obj), ); + if (commonKeys.length === 0) { + return null; + } - // Map the grouped values to a new object - const result = Object.fromEntries( - Object.entries(grouped).map(([key, values]: [string, unknown[]]) => { + // Check for unresolvable conflicts: values that aren't all numeric or all + // the same. + for (const key of allKeys) { + const objectsWithKey = nonEmptyObjects.filter((obj) => key in obj); + if (objectsWithKey.length > 1) { + const values = objectsWithKey.map((obj) => obj[key]); const allNumeric = values.every((v: unknown) => typeof v === "number"); const allSame = new Set(values).size === 1; + if (!allNumeric && !allSame) { + return nonEmptyObjects; + } + } + } - if (allNumeric) - return [key, values.reduce((acc, v) => acc + (v as number), 0)]; - if (allSame) return [key, values[0]]; - return [key, null]; // Mark conflict - }), - ); + // Merge common keys: sum numeric values, preserve identical values, mark + // conflicts as null. + const result: Record = {}; + for (const key of commonKeys) { + const values = nonEmptyObjects.map((obj) => obj[key]); + const allNumeric = values.every((v: unknown) => typeof v === "number"); + const allSame = new Set(values).size === 1; + + if (allNumeric) { + result[key] = values.reduce((acc, v) => acc + (v as number), 0); + } else if (allSame) { + result[key] = values[0]; + } else { + result[key] = null; + } + } + + // Add non-common keys from the first object that has them. + for (const obj of nonEmptyObjects) { + for (const key of Object.keys(obj)) { + if (!commonKeys.includes(key) && !(key in result)) { + result[key] = obj[key]; + } + } + } + // If any conflicts were marked, return original objects. return Object.values(result).some((v: unknown) => v === null) ? nonEmptyObjects : result; From 396e1c3de530133c1a031adb0f2cf81b56feeb66 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 12 Dec 2025 14:24:43 +0000 Subject: [PATCH 8/9] fix: add handling for `null` objects --- .../RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index 6b7c328ee238a..4d2b681def1f8 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -29,9 +29,16 @@ type RequestLogsRowProps = { * @returns The merged object. */ function tokenUsageMetadataMerge( - ...objects: Array + ...objects: Array< + AIBridgeInterception["token_usages"][number]["metadata"] | null + > ): unknown { - const nonEmptyObjects = objects.filter((obj) => Object.keys(obj).length > 0); + const validObjects = objects.filter((obj) => obj !== null); + + // Filter out empty objects + const nonEmptyObjects = validObjects.filter( + (obj) => Object.keys(obj).length > 0, + ); if (nonEmptyObjects.length === 0) { return null; } From 52cadd3720fe833bdd861a977e0351555d5c8bcb Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 12 Dec 2025 14:25:44 +0000 Subject: [PATCH 9/9] fix: add a merged type for `tokenUsageMetadata` --- .../RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx index 4d2b681def1f8..ea224f4a6cd68 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx @@ -21,6 +21,11 @@ type RequestLogsRowProps = { interception: AIBridgeInterception; }; +type TokenUsageMetadataMerged = + | null + | Record + | Array>; + /** * This function merges multiple objects with the same keys into a single object. * It's super unconventional, but it's only a temporary workaround until we @@ -32,7 +37,7 @@ function tokenUsageMetadataMerge( ...objects: Array< AIBridgeInterception["token_usages"][number]["metadata"] | null > -): unknown { +): TokenUsageMetadataMerged { const validObjects = objects.filter((obj) => obj !== null); // Filter out empty objects