From 78e8c1b32698bc99432ac879fe535acbb6939a7a Mon Sep 17 00:00:00 2001 From: Nathan Scott Date: Fri, 4 Sep 2026 17:47:52 +1000 Subject: [PATCH 1/3] fix(performance): fix API request preview to match actual request The "Preview API request body" section was displaying hardcoded placeholder values instead of the actual request being sent to the backend. Fixed to dynamically generate the preview from current form state: - Use actual model_id, system (GPU), and workload parameters - Respect backend-specific gpu_memory_utilization instead of hardcoded 0.90 - Include all quantization modes (gemm_quant_mode, kvcache_quant_mode) - Include backend version, prefix tokens, and pipeline parallel size - Only show optional fields when they're actually set (not empty) Also added comprehensive unit tests for the estimate adapter request body construction to prevent regression. Signed-off-by: Nathan Scott Co-Authored-By: Claude Haiku 4.5 --- app/performance/PerformanceEstimate.tsx | 39 +- lib/api/__tests__/estimate-adapter.test.ts | 439 +++++++++++++++++++++ test-api-categories.sh | 197 --------- test-api-kv-detection.sh | 111 ------ test-cloudflare-integration.mjs | 36 -- 5 files changed, 463 insertions(+), 359 deletions(-) create mode 100644 lib/api/__tests__/estimate-adapter.test.ts delete mode 100755 test-api-categories.sh delete mode 100755 test-api-kv-detection.sh delete mode 100644 test-cloudflare-integration.mjs diff --git a/app/performance/PerformanceEstimate.tsx b/app/performance/PerformanceEstimate.tsx index 3a2329a..13b629b 100644 --- a/app/performance/PerformanceEstimate.tsx +++ b/app/performance/PerformanceEstimate.tsx @@ -606,7 +606,7 @@ export default function QuickEstimate() { memory: { weight_precision: testWeightPrecision.toLowerCase(), kv_cache_precision: testKVCachePrecision.toLowerCase(), - gpu_memory_utilization: 0.90 + gpu_memory_utilization: currentAicGpu?.gpuMemoryUtilization ?? 0.90 }, gpu: { gpu_type: gpu, @@ -1874,7 +1874,29 @@ export default function QuickEstimate() { - {showApi &&
{API_PREVIEW}
} + {showApi && ( +
+            {JSON.stringify({
+              model_path: model || '(select model)',
+              system: gpu || '(select GPU)',
+              backend: inferenceBackend,
+              isl: testISL,
+              osl: testOSL,
+              batch_size: testConcurrentUsers,
+              tp_size: testResult?.memory_analysis.tp_size ?? 'auto',
+              pp_size: testResult?.parallelism_strategy.pp_size ?? testPpSize,
+              ...(testPrefix > 0 && { prefix: testPrefix }),
+              ...(backendVersion && { backend_version: backendVersion }),
+              ...(testWeightPrecision === 'FP8' && { gemm_quant_mode: 'fp8' }),
+              ...(testWeightPrecision === 'INT8' && { gemm_quant_mode: 'int8_wo' }),
+              ...(testWeightPrecision === 'INT4' && { gemm_quant_mode: 'int4_wo' }),
+              ...(testWeightPrecision === 'MXFP4' && { gemm_quant_mode: 'mxfp4' }),
+              ...(testWeightPrecision === 'NVFP4' && { gemm_quant_mode: 'nvfp4' }),
+              ...(testKVCachePrecision === 'FP8' && { kvcache_quant_mode: 'fp8' }),
+              ...(testKVCachePrecision === 'NVFP4' && { kvcache_quant_mode: 'nvfp4' })
+            }, null, 2)}
+          
+ )} ); @@ -1895,16 +1917,3 @@ function ConstraintRow({ label, detail, status, term }: { label: string; detail: ); } - - -const API_PREVIEW = `{ - "model": { "model_id": "meta-llama/Llama-3.1-8B-Instruct", "max_model_len": "auto" }, - "workload": { "isl_tokens": 100, "osl_tokens": 50, "prefix_cache_hit_rate": 0.0, - "requests_per_day": 1000000, "peak_multiplier": 3.0 }, - "memory": { "weight_precision": "bf16", "kv_cache_precision": "fp16", - "gpu_memory_utilization": 0.90 }, - "hardware": { "gpu_type": "H100_80GB" }, - "parallelism": { "tensor_parallel_size": "auto" }, - "engine": { "runtime": "vllm", "block_size": 16, "max_num_seqs": 256, - "enable_prefix_caching": true, "enable_chunked_prefill": "auto" } -}`; diff --git a/lib/api/__tests__/estimate-adapter.test.ts b/lib/api/__tests__/estimate-adapter.test.ts new file mode 100644 index 0000000..6f4d6eb --- /dev/null +++ b/lib/api/__tests__/estimate-adapter.test.ts @@ -0,0 +1,439 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fetchEstimateAsInferenceResult, type EstimateAdapterInput, EstimateError } from '../estimate-adapter' + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const VALID_INPUT: EstimateAdapterInput = { + model_path: 'meta-llama/Llama-3.1-8B-Instruct', + system: 'h200_sxm', + isl: 2048, + osl: 128, + batch_size: 32, + tp_size: 1, + backend: 'vllm', + gpu_memory_utilization: 0.9, +} + +const VALID_RESPONSE = { + status: 'success', + memory_breakdown: { + weights_bytes: 16_000_000_000, + kv_cache_bytes: 4_000_000_000, + }, + ttft: 100, + tpot: 20, + serving_config: { + tensor_parallel_size: 1, + max_model_len: 2176, + max_num_seqs: 32, + gpu_memory_utilization: 0.9, + enable_chunked_prefill: false, + enable_prefix_caching: false, + quantization: 'auto', + }, +} + +function mockFetchOk(data: unknown) { + return vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(data), + }) +} + +// ─── Request Body Construction Tests ────────────────────────────────────────── + +describe('fetchEstimateAsInferenceResult - request body construction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('includes all required fields in the request', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult(VALID_INPUT) + + const call = mockFetch.mock.calls[0] + const url = call[0] as string + const body = JSON.parse(call[1].body as string) + + expect(url).toContain('/api/estimate') + expect(body).toHaveProperty('model_path', 'meta-llama/Llama-3.1-8B-Instruct') + expect(body).toHaveProperty('system', 'h200_sxm') + expect(body).toHaveProperty('backend', 'vllm') + expect(body).toHaveProperty('isl', 2048) + expect(body).toHaveProperty('osl', 128) + expect(body).toHaveProperty('batch_size', 32) + expect(body).toHaveProperty('tp_size', 1) + }) + + it('includes backend_version when provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + backend_version: '0.24.0', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('backend_version', '0.24.0') + }) + + it('omits backend_version when not provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + const input: EstimateAdapterInput = { + ...VALID_INPUT, + backend_version: undefined, + } + await fetchEstimateAsInferenceResult(input) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).not.toHaveProperty('backend_version') + }) + + it('includes prefix when > 0', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + prefix: 512, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('prefix', 512) + }) + + it('omits prefix when 0 or undefined', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + prefix: 0, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).not.toHaveProperty('prefix') + }) + + it('includes pp_size when > 1', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + pp_size: 2, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('pp_size', 2) + }) + + it('omits pp_size when undefined or <= 1', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + pp_size: undefined, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).not.toHaveProperty('pp_size') + }) + + it('maps FP8 weight precision to gemm_quant_mode: fp8', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + gemm_quant_mode: 'fp8', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('gemm_quant_mode', 'fp8') + }) + + it('maps INT8 weight precision to gemm_quant_mode: int8_wo', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + gemm_quant_mode: 'int8_wo', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('gemm_quant_mode', 'int8_wo') + }) + + it('maps INT4 weight precision to gemm_quant_mode: int4_wo', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + gemm_quant_mode: 'int4_wo', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('gemm_quant_mode', 'int4_wo') + }) + + it('maps MXFP4 weight precision to gemm_quant_mode: mxfp4', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + gemm_quant_mode: 'mxfp4', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('gemm_quant_mode', 'mxfp4') + }) + + it('maps NVFP4 weight precision to gemm_quant_mode: nvfp4', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + gemm_quant_mode: 'nvfp4', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('gemm_quant_mode', 'nvfp4') + }) + + it('omits gemm_quant_mode when not provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + gemm_quant_mode: undefined, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).not.toHaveProperty('gemm_quant_mode') + }) + + it('maps FP8 KV cache precision to kvcache_quant_mode: fp8', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + kvcache_quant_mode: 'fp8', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('kvcache_quant_mode', 'fp8') + }) + + it('maps NVFP4 KV cache precision to kvcache_quant_mode: nvfp4', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + kvcache_quant_mode: 'nvfp4', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('kvcache_quant_mode', 'nvfp4') + }) + + it('omits kvcache_quant_mode when not provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + kvcache_quant_mode: undefined, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).not.toHaveProperty('kvcache_quant_mode') + }) + + it('includes moe_quant_mode when provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + moe_quant_mode: 'w4a16_mxfp4', + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('moe_quant_mode', 'w4a16_mxfp4') + }) + + it('omits moe_quant_mode when not provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + moe_quant_mode: undefined, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).not.toHaveProperty('moe_quant_mode') + }) + + it('omits model_config when hf_model_config is not provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + hf_model_config: undefined, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).not.toHaveProperty('model_config') + }) + + it('includes model_config when hf_model_config is provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + const hfConfig = { num_hidden_layers: 32, hidden_size: 4096 } + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + hf_model_config: hfConfig, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('model_config', hfConfig) + }) + + it('defaults backend to vllm when not provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + const input: EstimateAdapterInput = { + ...VALID_INPUT, + backend: undefined, + } + await fetchEstimateAsInferenceResult(input) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('backend', 'vllm') + }) + + it('handles MoE models with moe_ep_size', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + moe_ep_size: 8, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('moe_ep_size', 8) + }) + + it('handles MoE models with moe_tp_size when moe_ep_size not provided', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult({ + ...VALID_INPUT, + moe_tp_size: 4, + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) + expect(body).toHaveProperty('moe_tp_size', 4) + }) + + it('includes include=config,memory in query params', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + await fetchEstimateAsInferenceResult(VALID_INPUT) + + const url = mockFetch.mock.calls[0][0] as string + expect(url).toContain('include=config,memory') + }) +}) + +// ─── Response Handling Tests ────────────────────────────────────────────────── + +describe('fetchEstimateAsInferenceResult - response handling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('throws EstimateError with correct code on API error', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ + status: 'failed', + error: { code: 'INVALID_REQUEST', message: 'Bad request' }, + }), + }) + vi.stubGlobal('fetch', mockFetch) + + await expect(fetchEstimateAsInferenceResult(VALID_INPUT)).rejects.toThrow( + expect.objectContaining({ + code: 'INVALID_REQUEST', + }) + ) + }) + + it('throws error on non-JSON response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.reject(new Error('Invalid JSON')), + }) + vi.stubGlobal('fetch', mockFetch) + + await expect(fetchEstimateAsInferenceResult(VALID_INPUT)).rejects.toThrow( + 'Invalid JSON' + ) + }) + + it('returns a valid InferenceConfigResult on success', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + const result = await fetchEstimateAsInferenceResult(VALID_INPUT) + + expect(result).toHaveProperty('memory_analysis') + expect(result).toHaveProperty('vllm_config') + expect(result).toHaveProperty('parallelism_strategy') + expect(result).toHaveProperty('bottleneck_analysis') + expect(result).toHaveProperty('diagnostics') + }) + + it('includes weight and KV cache memory in response', async () => { + const mockFetch = mockFetchOk(VALID_RESPONSE) + vi.stubGlobal('fetch', mockFetch) + + const result = await fetchEstimateAsInferenceResult(VALID_INPUT) + + expect(result.memory_analysis).toHaveProperty('weight_gb') + expect(result.memory_analysis).toHaveProperty('kv_cache_used_gb') + }) +}) diff --git a/test-api-categories.sh b/test-api-categories.sh deleted file mode 100755 index dea63fd..0000000 --- a/test-api-categories.sh +++ /dev/null @@ -1,197 +0,0 @@ -#!/bin/bash - -# Test API with models from each KV category -# Usage: HF_TOKEN= ./test-api-categories.sh [local|prod] - -MODE=${1:-local} - -# Validate HF_TOKEN is set -if [ -z "$HF_TOKEN" ]; then - echo "❌ Error: HF_TOKEN environment variable is required" - echo "" - echo "Usage:" - echo " HF_TOKEN= ./test-api-categories.sh [local|prod]" - echo "" - echo "Get your token at: https://huggingface.co/settings/tokens" - exit 1 -fi - -if [ "$MODE" = "prod" ]; then - API_URL="https://gpu-calc-v2.vercel.app/api/v1/config" - echo "🌍 Testing PRODUCTION API: $API_URL" -else - API_URL="http://localhost:3005/api/v1/config" - echo "🏠 Testing LOCAL API: $API_URL" -fi - -echo "════════════════════════════════════════════════════════════════════════════════════════════════" -echo "" - -# Test models: "model|expected_category|expect_kv_zero" -TESTS=( - "nvidia/Nemotron-Mini-4B-Instruct|KV-1|false" - "deepseek-ai/DeepSeek-V2-Lite|KV-2|false" - "microsoft/phi-3-mini-4k-instruct|KV-3a|false" - "google/gemma-3-4b-it|KV-3b|false" - "google/gemma-3-27b-it|KV-3b|false" - "tiiuae/falcon-mamba-7b|KV-5a|true" - "state-spaces/mamba-2.8b|KV-5a|true" - "ai21labs/AI21-Jamba-Mini-1.5|KV-5b|false" - "nvidia/Nemotron-H-4B-Base-8K|KV-5b|false" - "deepseek-ai/DeepSeek-R1|KV-2|false" - "zai-org/GLM-5.1-FP8|KV-2|false" -) - -# Colors -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -GRAY='\033[0;90m' -NC='\033[0m' # No Color - -# Counters -PASS_COUNT=0 -FAIL_COUNT=0 -WARN_COUNT=0 - -printf "%-6s %-8s %-45s %-12s %-14s %-12s %s\n" "Status" "Category" "Model" "Weight(GB)" "KV_cache(GB)" "KV/req(MB)" "Notes" -echo "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────" - -for test in "${TESTS[@]}"; do - IFS='|' read -r model expected_category expect_kv_zero <<< "$test" - - # Call API with timeout - response=$(curl -s --max-time 30 -X POST "$API_URL" \ - -H "Content-Type: application/json" \ - -d '{ - "model_name": "'"$model"'", - "precision": "FP16", - "gpu_type": "h200-141gb", - "concurrent_users": 97, - "isl": 1000, - "osl": 150, - "workload_type": "chat", - "sla_priority": "ttft", - "hf_token": "'"$HF_TOKEN"'" - }' 2>&1) - - # Check if request succeeded - if ! echo "$response" | jq -e '.success' > /dev/null 2>&1; then - # Request failed - error_msg=$(echo "$response" | jq -r '.message // .error // "Unknown error"' 2>/dev/null || echo "API timeout or network error") - printf "${RED}%-6s${NC} %-8s %-45s %-12s %-14s %-12s %s\n" "✗" "$expected_category" "$model" "ERROR" "-" "-" "$error_msg" - FAIL_COUNT=$((FAIL_COUNT + 1)) - sleep 2 - continue - fi - - # Parse response fields - weight_gb=$(echo "$response" | jq -r '.data.memory_analysis.weight_gb // 0') - kv_cache_gb=$(echo "$response" | jq -r '.data.memory_analysis.kv_cache_used_gb // 0') - actual_category=$(echo "$response" | jq -r '.data.memory_analysis.kv_category // "UNKNOWN"') - warnings=$(echo "$response" | jq -r '.data.warnings // [] | join("; ")') - - # Calculate KV per request in MB - kv_per_req_mb=$(echo "$kv_cache_gb * 1024 / 97" | bc -l | xargs printf "%.1f") - - # Check if using estimation - is_estimated="" - if echo "$warnings" | grep -qi "estimated"; then - is_estimated="⚠️ estimated" - fi - - # Validate category - category_pass=false - if [ "$actual_category" = "$expected_category" ]; then - category_pass=true - fi - - # Validate KV cache (zero vs non-zero) - kv_zero_pass=false - if [ "$expect_kv_zero" = "true" ]; then - # Should be zero - if [ "$(echo "$kv_cache_gb == 0" | bc -l)" -eq 1 ]; then - kv_zero_pass=true - fi - else - # Should be non-zero - if [ "$(echo "$kv_cache_gb > 0" | bc -l)" -eq 1 ]; then - kv_zero_pass=true - fi - fi - - # Overall pass/fail - overall_pass=false - if $category_pass && $kv_zero_pass; then - overall_pass=true - fi - - # Build notes - notes="" - if ! $category_pass; then - notes="${notes}got $actual_category; " - fi - if ! $kv_zero_pass; then - if [ "$expect_kv_zero" = "true" ]; then - notes="${notes}expected KV=0; " - else - notes="${notes}expected KV>0; " - fi - fi - if [ -n "$is_estimated" ]; then - notes="${notes}${is_estimated}; " - fi - notes=$(echo "$notes" | sed 's/; $//') - - # Format output - weight_str=$(printf "%.1f" "$weight_gb") - kv_cache_str=$(printf "%.1f" "$kv_cache_gb") - - # Determine status symbol and color - if $overall_pass; then - if [ -n "$is_estimated" ]; then - status="${YELLOW}⚠${NC}" - notes="$is_estimated" - WARN_COUNT=$((WARN_COUNT + 1)) - else - status="${GREEN}✓${NC}" - PASS_COUNT=$((PASS_COUNT + 1)) - fi - else - status="${RED}✗${NC}" - FAIL_COUNT=$((FAIL_COUNT + 1)) - fi - - printf "%-6s %-8s %-45s %-12s %-14s %-12s %s\n" \ - "$status" \ - "$expected_category" \ - "$model" \ - "${weight_str}GB" \ - "${kv_cache_str}GB" \ - "${kv_per_req_mb}MB" \ - "$notes" - - # Rate limit - sleep 2 -done - -echo "" -echo "════════════════════════════════════════════════════════════════════════════════════════════════" -echo "" -echo "SUMMARY:" -echo " ${GREEN}✓ PASS:${NC} $PASS_COUNT" -echo " ${YELLOW}⚠ WARN:${NC} $WARN_COUNT (estimated architecture)" -echo " ${RED}✗ FAIL:${NC} $FAIL_COUNT" -echo "" -echo "Total tests: ${#TESTS[@]}" - -if [ $FAIL_COUNT -eq 0 ]; then - echo "" - echo "${GREEN}All tests passed!${NC}" - exit 0 -else - echo "" - echo "${RED}Some tests failed${NC}" - exit 1 -fi diff --git a/test-api-kv-detection.sh b/test-api-kv-detection.sh deleted file mode 100755 index 991a8f1..0000000 --- a/test-api-kv-detection.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash - -# Test API KV category detection with HF config fetch -# Usage: HF_TOKEN= ./test-api-kv-detection.sh - -if [ -z "$HF_TOKEN" ]; then - echo "❌ Error: HF_TOKEN environment variable is required" - echo "Usage: HF_TOKEN= ./test-api-kv-detection.sh" - exit 1 -fi - -API_URL="http://localhost:3005/api/v1/config" - -echo "Testing API KV category detection..." -echo "════════════════════════════════════════════════════════════" -echo "" - -# Test 1: Public model (DeepSeek V2 Lite) - no token needed, should detect KV-2 -echo "Test 1: deepseek-ai/DeepSeek-V2-Lite (public, KV-2)" -result1=$(curl -s -X POST "$API_URL" \ - -H "Content-Type: application/json" \ - -d '{ - "model_name": "deepseek-ai/DeepSeek-V2-Lite", - "precision": "FP16", - "gpu_type": "h200-141gb", - "concurrent_users": 97, - "isl": 1000, - "osl": 150, - "workload_type": "chat", - "sla_priority": "ttft" - }') - -category1=$(echo "$result1" | jq -r '.data.memory_analysis.kv_category // "ERROR"') -weight1=$(echo "$result1" | jq -r '.data.memory_analysis.weight_gb // 0') - -if [ "$category1" = "KV-2" ]; then - echo " ✅ PASS - Category: $category1, Weight: ${weight1}GB" -else - echo " ❌ FAIL - Got: $category1 (expected KV-2)" -fi -echo "" - -# Test 2: Gated model (Gemma 3 4B) - requires token, should detect KV-3b -echo "Test 2: google/gemma-3-4b-it (gated, KV-3b, requires token)" -result2=$(curl -s -X POST "$API_URL" \ - -H "Content-Type: application/json" \ - -d "{ - \"model_name\": \"google/gemma-3-4b-it\", - \"precision\": \"FP16\", - \"gpu_type\": \"h200-141gb\", - \"concurrent_users\": 97, - \"isl\": 1000, - \"osl\": 150, - \"workload_type\": \"chat\", - \"sla_priority\": \"ttft\", - \"hf_token\": \"$HF_TOKEN\" - }") - -category2=$(echo "$result2" | jq -r '.data.memory_analysis.kv_category // "ERROR"') -weight2=$(echo "$result2" | jq -r '.data.memory_analysis.weight_gb // 0') - -if [ "$category2" = "KV-3b" ]; then - echo " ✅ PASS - Category: $category2, Weight: ${weight2}GB" -else - echo " ❌ FAIL - Got: $category2 (expected KV-3b)" -fi -echo "" - -# Test 3: SSM model (Mamba) - no token needed, should detect KV-5a -echo "Test 3: state-spaces/mamba-2.8b (public, KV-5a)" -result3=$(curl -s -X POST "$API_URL" \ - -H "Content-Type: application/json" \ - -d '{ - "model_name": "state-spaces/mamba-2.8b", - "precision": "FP16", - "gpu_type": "h200-141gb", - "concurrent_users": 97, - "isl": 1000, - "osl": 150, - "workload_type": "chat", - "sla_priority": "ttft" - }') - -category3=$(echo "$result3" | jq -r '.data.memory_analysis.kv_category // "ERROR"') -weight3=$(echo "$result3" | jq -r '.data.memory_analysis.weight_gb // 0') - -if [ "$category3" = "KV-5a" ]; then - echo " ✅ PASS - Category: $category3, Weight: ${weight3}GB" -else - echo " ❌ FAIL - Got: $category3 (expected KV-5a)" -fi -echo "" - -echo "════════════════════════════════════════════════════════════" - -# Summary -total=3 -pass=0 -[ "$category1" = "KV-2" ] && pass=$((pass + 1)) -[ "$category2" = "KV-3b" ] && pass=$((pass + 1)) -[ "$category3" = "KV-5a" ] && pass=$((pass + 1)) - -echo "Results: $pass/$total tests passed" - -if [ $pass -eq $total ]; then - echo "✅ All tests passed!" - exit 0 -else - echo "❌ Some tests failed" - exit 1 -fi diff --git a/test-cloudflare-integration.mjs b/test-cloudflare-integration.mjs deleted file mode 100644 index 596bee8..0000000 --- a/test-cloudflare-integration.mjs +++ /dev/null @@ -1,36 +0,0 @@ -// Test Cloudflare integration directly -import { fetchGPUPricing, aggregateGPUPricing } from './lib/api/cloudflare.ts' - -async function test() { - console.log('Fetching GPU pricing from Cloudflare Worker...') - - try { - const data = await fetchGPUPricing({ gpu: 'H100' }) - console.log(`\nTotal H100 prices: ${data.prices.length}`) - console.log(`Source: ${data.source}`) - console.log(`Timestamp: ${data.timestamp}`) - - if (data.prices.length > 0) { - console.log('\nSample price record:') - console.log(JSON.stringify(data.prices[0], null, 2)) - - // Test aggregation - const h100_80gb_prices = data.prices.filter(p => p.vram_gb === 80) - console.log(`\nH100 80GB prices: ${h100_80gb_prices.length}`) - - const onDemand = aggregateGPUPricing(h100_80gb_prices, 'on_demand') - const spot = aggregateGPUPricing(h100_80gb_prices, 'spot') - - console.log('\nOn-demand pricing:') - console.log(JSON.stringify(onDemand, null, 2)) - - console.log('\nSpot pricing:') - console.log(JSON.stringify(spot, null, 2)) - } - } catch (error) { - console.error('Error:', error.message) - console.error(error.stack) - } -} - -test() From 186d32c381588e172ffd759701dc60cb13705d6f Mon Sep 17 00:00:00 2001 From: Nathan Scott Date: Fri, 4 Sep 2026 18:15:17 +1000 Subject: [PATCH 2/3] refactor(performance): extract shared request body builder for API preview and copy Extract the estimate request body construction into a reusable `buildEstimateRequestBody()` function. Both the preview display and "Copy API request" button now use the same builder, ensuring they stay in sync and preventing divergence. This addresses CodeRabbit's suggestion to avoid duplicating request construction logic across paths while preserving all request-specific behavior (tp_size, pp_size inclusion, precision modes, backend version, prefix tokens, and MoE fields). Signed-off-by: Nathan Scott Co-Authored-By: Claude Haiku 4.5 --- app/performance/PerformanceEstimate.tsx | 71 +++++++++---------------- 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/app/performance/PerformanceEstimate.tsx b/app/performance/PerformanceEstimate.tsx index 13b629b..95bc73a 100644 --- a/app/performance/PerformanceEstimate.tsx +++ b/app/performance/PerformanceEstimate.tsx @@ -589,38 +589,35 @@ export default function QuickEstimate() { setTimeout(() => setShowToast(false), 5000); }; + // Build the /api/estimate request body from current form state + const buildEstimateRequestBody = React.useCallback(() => { + return { + model_path: model || '(select model)', + system: gpu || '(select GPU)', + backend: inferenceBackend, + isl: testISL, + osl: testOSL, + batch_size: testConcurrentUsers, + tp_size: testResult?.memory_analysis.tp_size ?? testTpSize, + pp_size: testResult?.parallelism_strategy.pp_size ?? testPpSize, + ...(testPrefix > 0 && { prefix: testPrefix }), + ...(backendVersion && { backend_version: backendVersion }), + ...(testWeightPrecision === 'FP8' && { gemm_quant_mode: 'fp8' }), + ...(testWeightPrecision === 'INT8' && { gemm_quant_mode: 'int8_wo' }), + ...(testWeightPrecision === 'INT4' && { gemm_quant_mode: 'int4_wo' }), + ...(testWeightPrecision === 'MXFP4' && { gemm_quant_mode: 'mxfp4' }), + ...(testWeightPrecision === 'NVFP4' && { gemm_quant_mode: 'nvfp4' }), + ...(testKVCachePrecision === 'FP8' && { kvcache_quant_mode: 'fp8' }), + ...(testKVCachePrecision === 'NVFP4' && { kvcache_quant_mode: 'nvfp4' }) + }; + }, [model, gpu, inferenceBackend, testISL, testOSL, testConcurrentUsers, testResult, testTpSize, testPpSize, testPrefix, backendVersion, testWeightPrecision, testKVCachePrecision]); + // Copy API request body to clipboard const handleCopyAPIRequest = async () => { if (!testResult) return; - const apiRequest: Record = { - model: { - model_id: model, - max_model_len: 'auto' - }, - workload: { - isl_tokens: testISL, - osl_tokens: testOSL, - concurrent_users: testConcurrentUsers, - }, - memory: { - weight_precision: testWeightPrecision.toLowerCase(), - kv_cache_precision: testKVCachePrecision.toLowerCase(), - gpu_memory_utilization: currentAicGpu?.gpuMemoryUtilization ?? 0.90 - }, - gpu: { - gpu_type: gpu, - tp_size: testResult.memory_analysis.tp_size, - replicas: testResult.memory_analysis.replicas - } - }; - - if (testResult.parallelism_strategy.pp_size > 1) { - (apiRequest.gpu as Record).pp_size = testResult.parallelism_strategy.pp_size; - } - try { - await navigator.clipboard.writeText(JSON.stringify(apiRequest, null, 2)); + await navigator.clipboard.writeText(JSON.stringify(buildEstimateRequestBody(), null, 2)); setToastMessage('api-copied'); setShowToast(true); setTimeout(() => setShowToast(false), 3000); @@ -1876,25 +1873,7 @@ export default function QuickEstimate() { {showApi && (
-            {JSON.stringify({
-              model_path: model || '(select model)',
-              system: gpu || '(select GPU)',
-              backend: inferenceBackend,
-              isl: testISL,
-              osl: testOSL,
-              batch_size: testConcurrentUsers,
-              tp_size: testResult?.memory_analysis.tp_size ?? 'auto',
-              pp_size: testResult?.parallelism_strategy.pp_size ?? testPpSize,
-              ...(testPrefix > 0 && { prefix: testPrefix }),
-              ...(backendVersion && { backend_version: backendVersion }),
-              ...(testWeightPrecision === 'FP8' && { gemm_quant_mode: 'fp8' }),
-              ...(testWeightPrecision === 'INT8' && { gemm_quant_mode: 'int8_wo' }),
-              ...(testWeightPrecision === 'INT4' && { gemm_quant_mode: 'int4_wo' }),
-              ...(testWeightPrecision === 'MXFP4' && { gemm_quant_mode: 'mxfp4' }),
-              ...(testWeightPrecision === 'NVFP4' && { gemm_quant_mode: 'nvfp4' }),
-              ...(testKVCachePrecision === 'FP8' && { kvcache_quant_mode: 'fp8' }),
-              ...(testKVCachePrecision === 'NVFP4' && { kvcache_quant_mode: 'nvfp4' })
-            }, null, 2)}
+            {JSON.stringify(buildEstimateRequestBody(), null, 2)}
           
)} From 4f44703fdd5f67e25d0671c3ba476e4a75be09e9 Mon Sep 17 00:00:00 2001 From: Nathan Scott Date: Fri, 4 Sep 2026 18:28:58 +1000 Subject: [PATCH 3/3] refactor(performance): include GPU-specific fields in request builder Add vram_gb and gpu_memory_utilization to buildEstimateRequestBody so the preview, copied JSON, and actual API call all use the same unified request shape. These fields are GPU-specific but critical to the actual calculation, so they should be visible in the preview to give users full transparency into what data is being sent. Addresses CodeRabbit suggestion to unify request construction across all paths. Signed-off-by: Nathan Scott Co-Authored-By: Claude Haiku 4.5 --- app/performance/PerformanceEstimate.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/performance/PerformanceEstimate.tsx b/app/performance/PerformanceEstimate.tsx index 95bc73a..8da1109 100644 --- a/app/performance/PerformanceEstimate.tsx +++ b/app/performance/PerformanceEstimate.tsx @@ -589,7 +589,7 @@ export default function QuickEstimate() { setTimeout(() => setShowToast(false), 5000); }; - // Build the /api/estimate request body from current form state + // Build the /api/estimate request body from current form state and GPU-specific values const buildEstimateRequestBody = React.useCallback(() => { return { model_path: model || '(select model)', @@ -600,6 +600,8 @@ export default function QuickEstimate() { batch_size: testConcurrentUsers, tp_size: testResult?.memory_analysis.tp_size ?? testTpSize, pp_size: testResult?.parallelism_strategy.pp_size ?? testPpSize, + vram_gb: currentAicGpu?.vramGb ?? null, + gpu_memory_utilization: currentAicGpu?.gpuMemoryUtilization, ...(testPrefix > 0 && { prefix: testPrefix }), ...(backendVersion && { backend_version: backendVersion }), ...(testWeightPrecision === 'FP8' && { gemm_quant_mode: 'fp8' }), @@ -610,7 +612,7 @@ export default function QuickEstimate() { ...(testKVCachePrecision === 'FP8' && { kvcache_quant_mode: 'fp8' }), ...(testKVCachePrecision === 'NVFP4' && { kvcache_quant_mode: 'nvfp4' }) }; - }, [model, gpu, inferenceBackend, testISL, testOSL, testConcurrentUsers, testResult, testTpSize, testPpSize, testPrefix, backendVersion, testWeightPrecision, testKVCachePrecision]); + }, [model, gpu, inferenceBackend, testISL, testOSL, testConcurrentUsers, testResult, testTpSize, testPpSize, currentAicGpu, testPrefix, backendVersion, testWeightPrecision, testKVCachePrecision]); // Copy API request body to clipboard const handleCopyAPIRequest = async () => {