diff --git a/packages/app/src/components/inference/ui/CustomCosts.tsx b/packages/app/src/components/inference/ui/CustomCosts.tsx
index c95bbb1..b2ee978 100644
--- a/packages/app/src/components/inference/ui/CustomCosts.tsx
+++ b/packages/app/src/components/inference/ui/CustomCosts.tsx
@@ -1,305 +1,13 @@
'use client';
-import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { memo } from 'react';
-import { track } from '@/lib/analytics';
-
-import { Button } from '@/components/ui/button';
-import {
- InputGroup,
- InputGroupAddon,
- InputGroupInput,
- InputGroupText,
-} from '@/components/ui/input-group';
-import { Skeleton } from '@/components/ui/skeleton';
-
-import { useInference } from '@/components/inference/InferenceContext';
-import { GPU_SPECS } from '@/lib/constants';
-
-// Memoized InputGroup component for GPU cost inputs
-const GpuCostInputGroup = memo(
- ({
- gpuKey,
- gpuLabel,
- inputValue,
- error,
- onChange,
- }: {
- gpuKey: string;
- gpuLabel: string;
- inputValue: string;
- error: string;
- onChange: (value: string) => void;
- }) => {
- return (
-
-
-
- {gpuLabel}:
-
- {
- onChange(e.target.value);
- }}
- className={error ? 'text-destructive' : ''}
- aria-invalid={!!error}
- />
-
- {error &&
{error}
}
-
- );
- },
-);
-
-GpuCostInputGroup.displayName = 'GpuCostInputGroup';
+import CustomGpuValuePanel from '@/components/inference/ui/CustomGpuValuePanel';
const CustomCosts = memo(({ loading }: { loading: boolean }) => {
- // Use the shared hardware context to ensure both charts use the same state
-
- const { selectedYAxisMetric, selectedPrecisions, selectedModel, selectedSequence, setUserCosts } =
- useInference();
-
- const [inputErrors, setInputErrors] = useState<{ [gpuKey: string]: string }>({});
- const [defaultValues, setDefaultValues] = useState<{ [gpuKey: string]: string }>({});
- const [lastCalculatedValues, setLastCalculatedValues] = useState<{
- [gpuKey: string]: string | number;
- }>({});
-
- // Track previous filter values to detect changes within this component
- const previousFiltersRef = useRef({
- model: selectedModel,
- sequence: selectedSequence,
- precisions: selectedPrecisions,
- yAxisMetric: selectedYAxisMetric,
- });
-
- // One cost input per physical GPU — derived from GPU_SPECS (deduplicated by design).
- const stableGpus = React.useMemo(() => {
- return Object.entries(GPU_SPECS)
- .filter(([, specs]) => specs.costr > 0)
- .map(([base, specs]) => ({ base, label: base.toUpperCase(), specs }));
- }, []);
-
- // Initialize default values and auto-apply so chart renders immediately
- useEffect(() => {
- const defaults: { [gpuKey: string]: string } = {};
- const numericDefaults: { [gpuKey: string]: number } = {};
-
- stableGpus.forEach((gpu) => {
- defaults[gpu.base] = gpu.specs.costr.toString();
- numericDefaults[gpu.base] = gpu.specs.costr;
- });
-
- setDefaultValues(defaults);
- setLastCalculatedValues(defaults);
- setInputErrors({});
- setUserCosts(numericDefaults);
- }, [stableGpus, setUserCosts]);
-
- // Reset last calculated values when filters change (model, sequence, precision, y-axis)
- useEffect(() => {
- const prevFilters = previousFiltersRef.current;
- const filtersChanged =
- prevFilters.model !== selectedModel ||
- prevFilters.sequence !== selectedSequence ||
- prevFilters.precisions.join(',') !== selectedPrecisions.join(',') ||
- prevFilters.yAxisMetric !== selectedYAxisMetric;
-
- if (filtersChanged) {
- // Reset last calculated values to defaults when filters change
- setLastCalculatedValues(defaultValues);
- setInputErrors({});
-
- // Update previous filters
- previousFiltersRef.current = {
- model: selectedModel,
- sequence: selectedSequence,
- precisions: selectedPrecisions,
- yAxisMetric: selectedYAxisMetric,
- };
- }
- }, [selectedModel, selectedSequence, selectedPrecisions, selectedYAxisMetric, defaultValues]);
-
- // Validate input value
- const validateInput = useCallback((value: string): string => {
- if (!value.trim()) {
- return '';
- }
-
- const numValue = parseFloat(value);
- if (isNaN(numValue)) {
- return 'Must be a valid number';
- }
- if (numValue < 0) {
- return 'Must be a positive number';
- }
-
- return '';
- }, []);
-
- // Handle input change with validation
- const handleInputChange = useCallback(
- (gpuKey: string, value: string) => {
- const validationError = validateInput(value);
-
- setInputErrors((prev) => ({
- ...prev,
- [gpuKey]: validationError,
- }));
- setLastCalculatedValues((prev) => ({
- ...prev,
- [gpuKey]: value,
- }));
- },
- [validateInput, setLastCalculatedValues, setUserCosts],
- );
-
- // Handle reset button click
- const handleReset = useCallback(() => {
- track('inference_custom_costs_reset', {
- metric: selectedYAxisMetric,
- gpuCount: stableGpus.length,
- });
- const defaultInputs: { [gpuKey: string]: number } = {};
-
- stableGpus.forEach((gpu) => {
- defaultInputs[gpu.base] = gpu.specs.costr;
- });
-
- setUserCosts(defaultInputs);
- setLastCalculatedValues(defaultInputs);
- setInputErrors({});
-
- // Don't update lastCalculatedValues here - we want to keep the last calculated values
- // so that when reset is clicked, we can compare the new (default) values with the last calculated values
- }, [stableGpus]);
-
- // Handle recalculate button click
- const handleRecalculate = useCallback(() => {
- const hasErrors = Object.values(inputErrors).some((error) => error !== '');
- if (hasErrors) {
- return;
- }
- track('inference_custom_costs_calculated', {
- metric: selectedYAxisMetric,
- gpuCount: stableGpus.length,
- });
-
- // Store the current values as the last calculated values before calculating
- const currentValues: { [gpuKey: string]: number } = {};
- stableGpus.forEach((gpu) => {
- const currentValue = lastCalculatedValues[gpu.base] ?? 0;
- if (currentValue) {
- currentValues[gpu.base] = parseFloat(currentValue.toString());
- }
- });
- setUserCosts(currentValues);
-
- // costs.calculateCosts();
- }, [inputErrors, stableGpus, lastCalculatedValues]);
-
- // Show skeleton when hardware data is loading
- // Use loading flag from useChartData to ensure consistency with parent component
- if (loading || stableGpus.length === 0) {
- return (
-
-
-
Custom GPU Costs
-
- Enter your own TCO (Total Cost of Ownership) values for each GPU in $/GPU/hr. These
- values will be used to calculate custom cost metrics.
-
-
-
-
- {Array.from({ length: 6 }).map((_, index) => {
- const skeletonId = `skeleton-input-${index + 1}`;
- return (
-
- );
- })}
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
Custom GPU Costs
-
- Enter your own TCO (Total Cost of Ownership) values for each GPU in $/GPU/hr. These values
- will be used to calculate custom cost metrics.
-
-
-
-
-
- {stableGpus.map((gpu) => {
- const inputValue = lastCalculatedValues[gpu.base] ?? '';
- const error = inputErrors[gpu.base];
-
- return (
- {
- handleInputChange(gpu.base, value);
- }}
- />
- );
- })}
-
-
-
- Reset
-
-
- {loading ? (
- <>
-
- Calculating...
- >
- ) : (
- 'Calculate'
- )}
-
-
-
-
- );
+ return ;
});
-CustomCosts.displayName = 'UserCostInputs';
+CustomCosts.displayName = 'CustomCosts';
export default CustomCosts;
diff --git a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx
new file mode 100644
index 0000000..ff03550
--- /dev/null
+++ b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx
@@ -0,0 +1,277 @@
+'use client';
+
+import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
+
+import { useInference } from '@/components/inference/InferenceContext';
+import {
+ buildAppliedCustomGpuValues,
+ buildDefaultCustomGpuValues,
+ didCustomGpuPanelFiltersChange,
+ validateCustomGpuValueInput,
+ type CustomGpuPanelFilters,
+} from '@/components/inference/ui/custom-gpu-value-panel-utils';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+ InputGroupText,
+} from '@/components/ui/input-group';
+import { Skeleton } from '@/components/ui/skeleton';
+import { track } from '@/lib/analytics';
+import { GPU_SPECS } from '@/lib/constants';
+
+type GpuSpec = (typeof GPU_SPECS)[keyof typeof GPU_SPECS];
+type GpuValuePanelKind = 'costs' | 'powers';
+
+const PANEL_CONFIG: Record<
+ GpuValuePanelKind,
+ {
+ title: string;
+ description: string;
+ sectionTestId: string;
+ calculateTestId: string;
+ inputIdPrefix: string;
+ resetEvent: string;
+ calculatedEvent: string;
+ getDefaultValue: (specs: GpuSpec) => number;
+ }
+> = {
+ costs: {
+ title: 'Custom GPU Costs',
+ description:
+ 'Enter your own TCO (Total Cost of Ownership) values for each GPU in $/GPU/hr. These values will be used to calculate custom cost metrics.',
+ sectionTestId: 'custom-costs-section',
+ calculateTestId: 'custom-costs-calculate',
+ inputIdPrefix: 'cost-input',
+ resetEvent: 'inference_custom_costs_reset',
+ calculatedEvent: 'inference_custom_costs_calculated',
+ getDefaultValue: (specs) => specs.costr,
+ },
+ powers: {
+ title: 'Custom GPU Powers',
+ description:
+ 'Enter your own Token Throughput per All in Utility MW (tok/s/MW) values for each GPU. These values will be used to calculate custom power metrics.',
+ sectionTestId: 'custom-powers-section',
+ calculateTestId: 'custom-powers-calculate',
+ // Preserve legacy input IDs so existing Cypress selectors keep passing.
+ inputIdPrefix: 'cost-input',
+ resetEvent: 'inference_custom_powers_reset',
+ calculatedEvent: 'inference_custom_powers_calculated',
+ getDefaultValue: (specs) => specs.power,
+ },
+};
+
+interface GpuValueInputGroupProps {
+ gpuKey: string;
+ gpuLabel: string;
+ inputIdPrefix: string;
+ inputValue: string;
+ error: string;
+ onChange: (value: string) => void;
+}
+
+const GpuValueInputGroup = memo(
+ ({ gpuKey, gpuLabel, inputIdPrefix, inputValue, error, onChange }: GpuValueInputGroupProps) => {
+ return (
+
+
+
+ {gpuLabel}:
+
+ {
+ onChange(e.target.value);
+ }}
+ className={error ? 'text-destructive' : ''}
+ aria-invalid={!!error}
+ />
+
+ {error &&
{error}
}
+
+ );
+ },
+);
+
+GpuValueInputGroup.displayName = 'GpuValueInputGroup';
+
+function renderSkeleton(title: string, description: string) {
+ return (
+
+ {title}
+ {description}
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+const CustomGpuValuePanel = memo(
+ ({ loading, kind }: { loading: boolean; kind: GpuValuePanelKind }) => {
+ const {
+ selectedYAxisMetric,
+ selectedPrecisions,
+ selectedModel,
+ selectedSequence,
+ setUserCosts,
+ setUserPowers,
+ } = useInference();
+
+ const config = PANEL_CONFIG[kind];
+ const applyValues = kind === 'costs' ? setUserCosts : setUserPowers;
+
+ const [inputErrors, setInputErrors] = useState>({});
+ const [defaultValues, setDefaultValues] = useState>({});
+ const [lastCalculatedValues, setLastCalculatedValues] = useState<
+ Record
+ >({});
+
+ const previousFiltersRef = useRef({
+ model: selectedModel,
+ sequence: selectedSequence,
+ precisions: selectedPrecisions,
+ yAxisMetric: selectedYAxisMetric,
+ });
+
+ const stableGpus = React.useMemo(() => {
+ // One input per physical GPU, and GPUs with a zero default for this metric are omitted.
+ return Object.entries(GPU_SPECS)
+ .filter(([, specs]) => config.getDefaultValue(specs) > 0)
+ .map(([base, specs]) => ({ base, label: base.toUpperCase(), specs }));
+ }, [config]);
+
+ useEffect(() => {
+ const { defaultValues: defaults, numericDefaults } = buildDefaultCustomGpuValues(
+ stableGpus,
+ config.getDefaultValue,
+ );
+
+ setDefaultValues(defaults);
+ setLastCalculatedValues(defaults);
+ setInputErrors({});
+ applyValues(numericDefaults);
+ }, [applyValues, config, stableGpus]);
+
+ useEffect(() => {
+ const prevFilters = previousFiltersRef.current;
+ const currentFilters: CustomGpuPanelFilters = {
+ model: selectedModel,
+ sequence: selectedSequence,
+ precisions: selectedPrecisions,
+ yAxisMetric: selectedYAxisMetric,
+ };
+ const filtersChanged = didCustomGpuPanelFiltersChange(prevFilters, currentFilters);
+
+ if (filtersChanged) {
+ setLastCalculatedValues(defaultValues);
+ setInputErrors({});
+ previousFiltersRef.current = currentFilters;
+ }
+ }, [defaultValues, selectedModel, selectedPrecisions, selectedSequence, selectedYAxisMetric]);
+
+ const handleInputChange = useCallback((gpuKey: string, value: string) => {
+ const validationError = validateCustomGpuValueInput(value);
+
+ setInputErrors((prev) => ({
+ ...prev,
+ [gpuKey]: validationError,
+ }));
+ setLastCalculatedValues((prev) => ({
+ ...prev,
+ [gpuKey]: value,
+ }));
+ }, []);
+
+ const handleReset = useCallback(() => {
+ track(config.resetEvent, {
+ metric: selectedYAxisMetric,
+ gpuCount: stableGpus.length,
+ });
+
+ const { numericDefaults } = buildDefaultCustomGpuValues(stableGpus, config.getDefaultValue);
+
+ applyValues(numericDefaults);
+ setLastCalculatedValues(numericDefaults);
+ setInputErrors({});
+ }, [applyValues, config, selectedYAxisMetric, stableGpus]);
+
+ const handleRecalculate = useCallback(() => {
+ const hasErrors = Object.values(inputErrors).some((error) => error !== '');
+ if (hasErrors) return;
+
+ track(config.calculatedEvent, {
+ metric: selectedYAxisMetric,
+ gpuCount: stableGpus.length,
+ });
+
+ const currentValues = buildAppliedCustomGpuValues(stableGpus, lastCalculatedValues);
+ applyValues(currentValues);
+ }, [applyValues, config, inputErrors, lastCalculatedValues, selectedYAxisMetric, stableGpus]);
+
+ if (loading || stableGpus.length === 0) {
+ return renderSkeleton(config.title, config.description);
+ }
+
+ return (
+
+ {config.title}
+ {config.description}
+
+
+
+ {stableGpus.map((gpu) => (
+ {
+ handleInputChange(gpu.base, value);
+ }}
+ />
+ ))}
+
+
+
+ Reset
+
+
+ Calculate
+
+
+
+
+ );
+ },
+);
+
+CustomGpuValuePanel.displayName = 'CustomGpuValuePanel';
+
+export default CustomGpuValuePanel;
diff --git a/packages/app/src/components/inference/ui/CustomPowers.tsx b/packages/app/src/components/inference/ui/CustomPowers.tsx
index 429900d..303a2f0 100644
--- a/packages/app/src/components/inference/ui/CustomPowers.tsx
+++ b/packages/app/src/components/inference/ui/CustomPowers.tsx
@@ -1,308 +1,11 @@
'use client';
-import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { memo } from 'react';
-import { track } from '@/lib/analytics';
-
-import { Button } from '@/components/ui/button';
-import {
- InputGroup,
- InputGroupAddon,
- InputGroupInput,
- InputGroupText,
-} from '@/components/ui/input-group';
-import { Skeleton } from '@/components/ui/skeleton';
-
-import { useInference } from '@/components/inference/InferenceContext';
-import { GPU_SPECS } from '@/lib/constants';
-
-// Memoized InputGroup component for GPU cost inputs
-const GpuCostInputGroup = memo(
- ({
- gpuKey,
- gpuLabel,
- inputValue,
- error,
- onChange,
- }: {
- gpuKey: string;
- gpuLabel: string;
- inputValue: string;
- error: string;
- onChange: (value: string) => void;
- }) => {
- return (
-
-
-
- {gpuLabel}:
-
- {
- onChange(e.target.value);
- }}
- className={error ? 'text-destructive' : ''}
- aria-invalid={!!error}
- />
-
- {error &&
{error}
}
-
- );
- },
-);
-
-GpuCostInputGroup.displayName = 'GpuCostInputGroup';
+import CustomGpuValuePanel from '@/components/inference/ui/CustomGpuValuePanel';
const CustomPowers = memo(({ loading }: { loading: boolean }) => {
- // Use the shared hardware context to ensure both charts use the same state
-
- const {
- selectedYAxisMetric,
- selectedPrecisions,
- selectedModel,
- selectedSequence,
- setUserPowers,
- } = useInference();
-
- const [inputErrors, setInputErrors] = useState<{ [gpuKey: string]: string }>({});
- const [defaultValues, setDefaultValues] = useState<{ [gpuKey: string]: string }>({});
- const [lastCalculatedValues, setLastCalculatedValues] = useState<{
- [gpuKey: string]: string | number;
- }>({});
-
- // Track previous filter values to detect changes within this component
- const previousFiltersRef = useRef({
- model: selectedModel,
- sequence: selectedSequence,
- precisions: selectedPrecisions,
- yAxisMetric: selectedYAxisMetric,
- });
-
- // One power input per physical GPU — derived from GPU_SPECS (deduplicated by design).
- const stableGpus = React.useMemo(() => {
- return Object.entries(GPU_SPECS)
- .filter(([, specs]) => specs.power > 0)
- .map(([base, specs]) => ({ base, label: base.toUpperCase(), specs }));
- }, []);
-
- // Initialize default values and auto-apply so chart renders immediately
- useEffect(() => {
- const defaults: { [gpuKey: string]: string } = {};
- const numericDefaults: { [gpuKey: string]: number } = {};
-
- stableGpus.forEach((gpu) => {
- defaults[gpu.base] = gpu.specs.power.toString();
- numericDefaults[gpu.base] = gpu.specs.power;
- });
-
- setDefaultValues(defaults);
- setLastCalculatedValues(defaults);
- setInputErrors({});
- setUserPowers(numericDefaults);
- }, [stableGpus, setUserPowers]);
-
- // Reset last calculated values when filters change (model, sequence, precision, y-axis)
- useEffect(() => {
- const prevFilters = previousFiltersRef.current;
- const filtersChanged =
- prevFilters.model !== selectedModel ||
- prevFilters.sequence !== selectedSequence ||
- prevFilters.precisions.join(',') !== selectedPrecisions.join(',') ||
- prevFilters.yAxisMetric !== selectedYAxisMetric;
-
- if (filtersChanged) {
- // Reset last calculated values to defaults when filters change
- setLastCalculatedValues(defaultValues);
- setInputErrors({});
-
- // Update previous filters
- previousFiltersRef.current = {
- model: selectedModel,
- sequence: selectedSequence,
- precisions: selectedPrecisions,
- yAxisMetric: selectedYAxisMetric,
- };
- }
- }, [selectedModel, selectedSequence, selectedPrecisions, selectedYAxisMetric, defaultValues]);
-
- // Validate input value
- const validateInput = useCallback((value: string): string => {
- if (!value.trim()) {
- return '';
- }
-
- const numValue = parseFloat(value);
- if (isNaN(numValue)) {
- return 'Must be a valid number';
- }
- if (numValue < 0) {
- return 'Must be a positive number';
- }
-
- return '';
- }, []);
-
- // Handle input change with validation
- const handleInputChange = useCallback(
- (gpuKey: string, value: string) => {
- const validationError = validateInput(value);
-
- setInputErrors((prev) => ({
- ...prev,
- [gpuKey]: validationError,
- }));
- setLastCalculatedValues((prev) => ({
- ...prev,
- [gpuKey]: value,
- }));
- },
- [validateInput, setLastCalculatedValues, setUserPowers],
- );
-
- // Handle reset button click
- const handleReset = useCallback(() => {
- track('inference_custom_powers_reset', {
- metric: selectedYAxisMetric,
- gpuCount: stableGpus.length,
- });
- const defaultInputs: { [gpuKey: string]: number } = {};
-
- stableGpus.forEach((gpu) => {
- defaultInputs[gpu.base] = gpu.specs.power;
- });
-
- setUserPowers(defaultInputs);
- setLastCalculatedValues(defaultInputs);
- setInputErrors({});
-
- // Don't update lastCalculatedValues here - we want to keep the last calculated values
- // so that when reset is clicked, we can compare the new (default) values with the last calculated values
- }, [stableGpus]);
-
- // Handle recalculate button click
- const handleRecalculate = useCallback(() => {
- const hasErrors = Object.values(inputErrors).some((error) => error !== '');
- if (hasErrors) {
- return;
- }
- track('inference_custom_powers_calculated', {
- metric: selectedYAxisMetric,
- gpuCount: stableGpus.length,
- });
-
- // Store the current values as the last calculated values before calculating
- const currentValues: { [gpuKey: string]: number } = {};
- stableGpus.forEach((gpu) => {
- const currentValue = lastCalculatedValues[gpu.base] ?? 0;
- if (currentValue) {
- currentValues[gpu.base] = parseFloat(currentValue.toString());
- }
- });
- setUserPowers(currentValues);
-
- // costs.calculateCosts();
- }, [inputErrors, stableGpus, lastCalculatedValues]);
-
- // Show skeleton when hardware data is loading
- // Use loading flag from useChartData to ensure consistency with parent component
- if (loading || stableGpus.length === 0) {
- return (
-
-
-
Custom GPU Costs
-
- Enter your own Token Throughput per All in Utility MW (tok/s/MW) values for each GPU.
- These values will be used to calculate custom power metrics.
-
-
-
-
- {Array.from({ length: 6 }).map((_, index) => {
- const skeletonId = `skeleton-input-${index + 1}`;
- return (
-
- );
- })}
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
Custom GPU Powers
-
- Enter your own Token Throughput per All in Utility MW (tok/s/MW) values for each GPU.
- These values will be used to calculate custom power metrics.
-
-
-
-
-
- {stableGpus.map((gpu) => {
- const inputValue = lastCalculatedValues[gpu.base] ?? '';
- const error = inputErrors[gpu.base];
-
- return (
- {
- handleInputChange(gpu.base, value);
- }}
- />
- );
- })}
-
-
-
- Reset
-
-
- {loading ? (
- <>
-
- Calculating...
- >
- ) : (
- 'Calculate'
- )}
-
-
-
-
- );
+ return ;
});
CustomPowers.displayName = 'CustomPowers';
diff --git a/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.test.ts b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.test.ts
new file mode 100644
index 0000000..0623b38
--- /dev/null
+++ b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildAppliedCustomGpuValues,
+ buildDefaultCustomGpuValues,
+ didCustomGpuPanelFiltersChange,
+ validateCustomGpuValueInput,
+ type CustomGpuPanelFilters,
+} from '@/components/inference/ui/custom-gpu-value-panel-utils';
+
+const baseFilters: CustomGpuPanelFilters = {
+ model: 'llama-3-1-8b',
+ sequence: '128-128',
+ precisions: ['fp8'],
+ yAxisMetric: 'tok/sec',
+};
+
+describe('validateCustomGpuValueInput', () => {
+ it('accepts blank and zero values, and rejects invalid or negative values', () => {
+ expect(validateCustomGpuValueInput('')).toBe('');
+ expect(validateCustomGpuValueInput(' ')).toBe('');
+ expect(validateCustomGpuValueInput('0')).toBe('');
+ expect(validateCustomGpuValueInput('wat')).toBe('Must be a valid number');
+ expect(validateCustomGpuValueInput('-1')).toBe('Must be a non-negative number');
+ });
+});
+
+describe('didCustomGpuPanelFiltersChange', () => {
+ it('returns false when filter values are unchanged', () => {
+ expect(didCustomGpuPanelFiltersChange(baseFilters, { ...baseFilters })).toBe(false);
+ });
+
+ it('returns true when a filter dimension changes', () => {
+ expect(
+ didCustomGpuPanelFiltersChange(baseFilters, {
+ ...baseFilters,
+ sequence: '2048-2048',
+ }),
+ ).toBe(true);
+ });
+});
+
+describe('buildDefaultCustomGpuValues', () => {
+ it('builds numeric and string defaults from the selected GPU metric', () => {
+ expect(
+ buildDefaultCustomGpuValues(
+ [
+ { base: 'h100', specs: { power: 0.73 } },
+ { base: 'b200', specs: { power: 1.2 } },
+ ],
+ (specs) => specs.power,
+ ),
+ ).toEqual({
+ defaultValues: { h100: '0.73', b200: '1.2' },
+ numericDefaults: { h100: 0.73, b200: 1.2 },
+ });
+ });
+});
+
+describe('buildAppliedCustomGpuValues', () => {
+ it('keeps zero values and omits blank entries only', () => {
+ expect(
+ buildAppliedCustomGpuValues([{ base: 'h100' }, { base: 'b200' }, { base: 'gb200' }], {
+ h100: '',
+ b200: '0',
+ gb200: 2.5,
+ }),
+ ).toEqual({
+ b200: 0,
+ gb200: 2.5,
+ });
+ });
+});
diff --git a/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.ts b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.ts
new file mode 100644
index 0000000..580ea92
--- /dev/null
+++ b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.ts
@@ -0,0 +1,74 @@
+export interface CustomGpuPanelFilters {
+ model: string;
+ sequence: string;
+ precisions: string[];
+ yAxisMetric: string;
+}
+
+export interface CustomGpuPanelEntry {
+ base: string;
+ specs: TSpecs;
+}
+
+export function validateCustomGpuValueInput(value: string): string {
+ if (!value.trim()) return '';
+
+ const numValue = parseFloat(value);
+ if (isNaN(numValue)) return 'Must be a valid number';
+ if (numValue < 0) return 'Must be a non-negative number';
+
+ return '';
+}
+
+export function didCustomGpuPanelFiltersChange(
+ prevFilters: CustomGpuPanelFilters,
+ currentFilters: CustomGpuPanelFilters,
+): boolean {
+ return (
+ prevFilters.model !== currentFilters.model ||
+ prevFilters.sequence !== currentFilters.sequence ||
+ prevFilters.precisions.join(',') !== currentFilters.precisions.join(',') ||
+ prevFilters.yAxisMetric !== currentFilters.yAxisMetric
+ );
+}
+
+export function buildDefaultCustomGpuValues(
+ stableGpus: CustomGpuPanelEntry[],
+ getDefaultValue: (specs: TSpecs) => number,
+): {
+ defaultValues: Record;
+ numericDefaults: Record;
+} {
+ const defaultValues: Record = {};
+ const numericDefaults: Record = {};
+
+ stableGpus.forEach((gpu) => {
+ const defaultValue = getDefaultValue(gpu.specs);
+ defaultValues[gpu.base] = defaultValue.toString();
+ numericDefaults[gpu.base] = defaultValue;
+ });
+
+ return { defaultValues, numericDefaults };
+}
+
+export function buildAppliedCustomGpuValues(
+ stableGpus: Array<{ base: string }>,
+ lastCalculatedValues: Record,
+): Record {
+ const currentValues: Record = {};
+
+ stableGpus.forEach((gpu) => {
+ const currentValue = lastCalculatedValues[gpu.base];
+
+ if (typeof currentValue === 'string') {
+ if (!currentValue.trim()) return;
+ currentValues[gpu.base] = parseFloat(currentValue);
+ return;
+ }
+
+ if (currentValue == null) return;
+ currentValues[gpu.base] = currentValue;
+ });
+
+ return currentValues;
+}