From 0024d20e2852ce5ea24f19aba7868aae9d6d9684 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 4 May 2026 16:28:47 -0300 Subject: [PATCH] fix(settings): include usage and budget records in json backups Export and restore usage history, domain cost history, and domain budget data so analytics and budget state survive settings transfers. Also adjust cost overview fallbacks to rank by request volume when cost data is unavailable, count conversationState entries in chat request logging, and align e2e coverage with the proxy settings route. --- open-sse/handlers/chatCore.ts | 2 + .../dashboard/costs/CostOverviewTab.tsx | 58 +++++++---- src/app/api/settings/export-json/route.ts | 9 ++ src/app/api/settings/import-json/route.ts | 4 +- src/lib/db/jsonMigration.ts | 99 ++++++++++++++++++- test-kiro.ts | 17 ++++ tests/e2e/combo-unification.spec.ts | 2 +- tests/e2e/proxy-registry.smoke.spec.ts | 3 +- tests/e2e/settings-toggles.spec.ts | 11 +-- 9 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 test-kiro.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 584808009c..2a6babbdab 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2827,6 +2827,8 @@ export async function handleChatCore({ translatedBody.messages?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || + (translatedBody.conversationState?.history?.length ?? 0) + + (translatedBody.conversationState?.currentMessage ? 1 : 0) || 0; log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); diff --git a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx index d02a86b9d4..14473409f9 100644 --- a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx +++ b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx @@ -304,18 +304,20 @@ export default function CostOverviewTab() { requestedModelCoveragePct: 0, streak: 0, }; + const hasCostData = summary.totalCost > 0; + const providersByCost = [...(analytics?.byProvider || [])] - .filter((provider) => provider.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((provider) => (hasCostData ? provider.cost > 0 : provider.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const modelsByCost = [...(analytics?.byModel || [])] - .filter((model) => model.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((model) => (hasCostData ? model.cost > 0 : model.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const apiKeysByCost = [...(analytics?.byApiKey || [])] - .filter((apiKey) => apiKey.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((apiKey) => (hasCostData ? apiKey.cost > 0 : apiKey.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const accountsByCost = [...(analytics?.byAccount || [])] - .filter((account) => account.cost > 0) - .sort((left, right) => right.cost - left.cost); + .filter((account) => (hasCostData ? account.cost > 0 : account.requests > 0)) + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); const avgCostPerRequest = summary.totalRequests > 0 ? summary.totalCost / summary.totalRequests : 0; const dailyTrend = analytics?.dailyTrend || []; @@ -642,7 +644,7 @@ export default function CostOverviewTab() { )} - {summary.totalCost <= 0 ? ( + {summary.totalCost <= 0 && summary.totalRequests <= 0 ? ( ) : ( <> -
- - -
+ {hasCostData && ( +
+ + +
+ )}
@@ -1047,14 +1057,16 @@ function TopListCard({ secondaryKey, secondaryLabel, locale, + hasCostData, }: { title: string; - rows: Array>; + rows: Array>; nameKey: string; valueKey: string; secondaryKey?: string; secondaryLabel?: string; locale: string; + hasCostData?: boolean; }) { const currencyFormatter = createCurrencyFormatter(locale); @@ -1080,7 +1092,11 @@ function TopListCard({ ) : null} - {currencyFormatter.format(Number(row[valueKey] || 0))} + {hasCostData || Number(row[valueKey] || 0) > 0 ? ( + currencyFormatter.format(Number(row[valueKey] || 0)) + ) : ( + Legacy / Free + )} @@ -1104,7 +1120,7 @@ function CostBreakdownTable({ locale, }: { title: string; - rows: Array>; + rows: Array>; columns: ColumnDef[]; locale: string; }) { @@ -1114,7 +1130,7 @@ function CostBreakdownTable({ const num = Number(value || 0); switch (format) { case "currency": - return currencyFormatter.format(num); + return num > 0 ? currencyFormatter.format(num) : "Legacy / Free"; case "compact": return new Intl.NumberFormat(locale, { notation: "compact" }).format(num); case "number": diff --git a/src/app/api/settings/export-json/route.ts b/src/app/api/settings/export-json/route.ts index 482a98b9a9..6eaa783728 100644 --- a/src/app/api/settings/export-json/route.ts +++ b/src/app/api/settings/export-json/route.ts @@ -6,6 +6,7 @@ import { getCombos, getApiKeys, } from "@/lib/localDb"; +import { getDbInstance } from "@/lib/db/core"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; /** @@ -32,12 +33,20 @@ export async function GET(request: Request) { const combos = await getCombos(); const apiKeys = await getApiKeys(); + const db = getDbInstance(); + const usageHistory = db.prepare("SELECT * FROM usage_history").all(); + const domainCostHistory = db.prepare("SELECT * FROM domain_cost_history").all(); + const domainBudgets = db.prepare("SELECT * FROM domain_budgets").all(); + const exportData = { settings: safeSettings, providerConnections, providerNodes, combos, apiKeys, + usageHistory, + domainCostHistory, + domainBudgets, // Metadata to identify export version _meta: { exportedAt: new Date().toISOString(), diff --git a/src/app/api/settings/import-json/route.ts b/src/app/api/settings/import-json/route.ts index 650336a313..0d7b4ebf67 100644 --- a/src/app/api/settings/import-json/route.ts +++ b/src/app/api/settings/import-json/route.ts @@ -67,7 +67,9 @@ export async function POST(request: Request) { console.log( `[JSON Import] Imported ${counts.connections} connections, ${counts.nodes} nodes, ` + - `${counts.combos} combos, ${counts.apiKeys} API keys` + `${counts.combos} combos, ${counts.apiKeys} API keys, ` + + `${counts.usageHistory} usage rows, ${counts.domainCostHistory} cost rows, ` + + `${counts.domainBudgets} budgets` ); return NextResponse.json({ diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts index 5e6e237485..1251b8b60d 100644 --- a/src/lib/db/jsonMigration.ts +++ b/src/lib/db/jsonMigration.ts @@ -32,6 +32,9 @@ export interface LegacyJsonData { combos?: unknown; keys?: unknown; }; + usageHistory?: Record[]; + domainCostHistory?: Record[]; + domainBudgets?: Record[]; } /** @@ -43,7 +46,15 @@ export interface LegacyJsonData { export function runJsonMigration( db: SqliteDatabase, data: LegacyJsonData -): { connections: number; nodes: number; combos: number; apiKeys: number } { +): { + connections: number; + nodes: number; + combos: number; + apiKeys: number; + usageHistory: number; + domainCostHistory: number; + domainBudgets: number; +} { const insertConn = db.prepare(` INSERT OR REPLACE INTO provider_connections ( id, provider, auth_type, name, email, priority, is_active, @@ -210,6 +221,89 @@ export function runJsonMigration( createdAt: apiKey.createdAt ?? new Date().toISOString(), }); } + // 7. Usage History + if (data.usageHistory && data.usageHistory.length > 0) { + const insertUsageHistory = db.prepare(` + INSERT OR REPLACE INTO usage_history ( + id, provider, model, connection_id, api_key_id, api_key_name, + tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, + tokens_reasoning, status, success, latency_ms, ttft_ms, error_code, timestamp + ) VALUES ( + @id, @provider, @model, @connection_id, @api_key_id, @api_key_name, + @tokens_input, @tokens_output, @tokens_cache_read, @tokens_cache_creation, + @tokens_reasoning, @status, @success, @latency_ms, @ttft_ms, @error_code, @timestamp + ) + `); + for (const row of data.usageHistory) { + insertUsageHistory.run({ + id: row.id, + provider: row.provider ?? null, + model: row.model ?? null, + connection_id: row.connection_id ?? null, + api_key_id: row.api_key_id ?? null, + api_key_name: row.api_key_name ?? null, + tokens_input: row.tokens_input ?? 0, + tokens_output: row.tokens_output ?? 0, + tokens_cache_read: row.tokens_cache_read ?? 0, + tokens_cache_creation: row.tokens_cache_creation ?? 0, + tokens_reasoning: row.tokens_reasoning ?? 0, + status: row.status ?? null, + success: row.success ?? 1, + latency_ms: row.latency_ms ?? 0, + ttft_ms: row.ttft_ms ?? 0, + error_code: row.error_code ?? null, + timestamp: row.timestamp, + }); + } + } + + // 8. Domain Cost History + if (data.domainCostHistory && data.domainCostHistory.length > 0) { + const insertCostHistory = db.prepare(` + INSERT OR REPLACE INTO domain_cost_history ( + id, api_key_id, cost, timestamp + ) VALUES ( + @id, @api_key_id, @cost, @timestamp + ) + `); + for (const row of data.domainCostHistory) { + insertCostHistory.run({ + id: row.id, + api_key_id: row.api_key_id, + cost: row.cost, + timestamp: row.timestamp, + }); + } + } + // 9. Domain Budgets + if (data.domainBudgets && data.domainBudgets.length > 0) { + const insertBudgets = db.prepare(` + INSERT OR REPLACE INTO domain_budgets ( + api_key_id, daily_limit_usd, weekly_limit_usd, monthly_limit_usd, + warning_threshold, reset_interval, reset_time, budget_reset_at, + last_budget_reset_at, warning_emitted_at, warning_period_start + ) VALUES ( + @api_key_id, @daily_limit_usd, @weekly_limit_usd, @monthly_limit_usd, + @warning_threshold, @reset_interval, @reset_time, @budget_reset_at, + @last_budget_reset_at, @warning_emitted_at, @warning_period_start + ) + `); + for (const row of data.domainBudgets) { + insertBudgets.run({ + api_key_id: row.api_key_id, + daily_limit_usd: row.daily_limit_usd, + weekly_limit_usd: row.weekly_limit_usd ?? 0, + monthly_limit_usd: row.monthly_limit_usd ?? 0, + warning_threshold: row.warning_threshold ?? 0.8, + reset_interval: row.reset_interval ?? "daily", + reset_time: row.reset_time ?? "00:00", + budget_reset_at: row.budget_reset_at ?? null, + last_budget_reset_at: row.last_budget_reset_at ?? null, + warning_emitted_at: row.warning_emitted_at ?? null, + warning_period_start: row.warning_period_start ?? null, + }); + } + } }); migrate(); @@ -219,5 +313,8 @@ export function runJsonMigration( nodes: (data.providerNodes ?? []).length, combos: (data.combos ?? []).length, apiKeys: (data.apiKeys ?? []).length, + usageHistory: (data.usageHistory ?? []).length, + domainCostHistory: (data.domainCostHistory ?? []).length, + domainBudgets: (data.domainBudgets ?? []).length, }; } diff --git a/test-kiro.ts b/test-kiro.ts new file mode 100644 index 0000000000..943462dbd2 --- /dev/null +++ b/test-kiro.ts @@ -0,0 +1,17 @@ +import { buildKiroPayload } from "./open-sse/translator/request/openai-to-kiro.js"; + +const messages = []; +for (let i = 0; i < 2; i++) { + messages.push({ role: "user", content: `user ${i}` }); + messages.push({ role: "assistant", content: `assistant ${i}` }); +} +messages.push({ role: "user", content: "use tool" }); +messages.push({ + role: "assistant", + tool_calls: [{ id: "call_1", function: { name: "test", arguments: "{}" } }], +}); +messages.push({ role: "tool", tool_call_id: "call_1", content: "ok" }); +messages.push({ role: "user", content: "last user" }); + +const payload = buildKiroPayload("kr/claude-sonnet-4.5", { messages, tools: [] }, false, {}); +console.log(JSON.stringify(payload, null, 2)); diff --git a/tests/e2e/combo-unification.spec.ts b/tests/e2e/combo-unification.spec.ts index 1a767a833a..ab84041f09 100644 --- a/tests/e2e/combo-unification.spec.ts +++ b/tests/e2e/combo-unification.spec.ts @@ -162,7 +162,7 @@ test.describe("Combo Unification", () => { await page.waitForLoadState("networkidle"); const sidebar = page.locator("aside, nav").first(); - await expect(sidebar.getByText("Combos")).toBeVisible(); + await expect(sidebar.getByText("Combos", { exact: true })).toBeVisible(); await expect(sidebar.getByText("Auto Combo")).toHaveCount(0); }); diff --git a/tests/e2e/proxy-registry.smoke.spec.ts b/tests/e2e/proxy-registry.smoke.spec.ts index 9ef481153a..f7f45c8218 100644 --- a/tests/e2e/proxy-registry.smoke.spec.ts +++ b/tests/e2e/proxy-registry.smoke.spec.ts @@ -172,8 +172,7 @@ test.describe("Proxy Registry smoke flow", () => { }); }); - await gotoDashboardRoute(page, "/dashboard/settings?tab=advanced"); - await page.waitForLoadState("networkidle"); + await gotoDashboardRoute(page, "/dashboard/system/proxy"); await expect(page.getByRole("heading", { name: "Proxy Registry" })).toBeVisible(); diff --git a/tests/e2e/settings-toggles.spec.ts b/tests/e2e/settings-toggles.spec.ts index 8f05a14cad..7988e16e68 100644 --- a/tests/e2e/settings-toggles.spec.ts +++ b/tests/e2e/settings-toggles.spec.ts @@ -30,9 +30,7 @@ test.describe("Settings Toggles", () => { ); test("Debug mode toggle should work", async ({ page }) => { - await gotoDashboardRoute(page, "/dashboard/settings"); - await waitForSettingsShell(page); - await page.getByRole("tab", { name: /advanced/i }).click(); + await gotoDashboardRoute(page, "/dashboard/system/proxy"); const debugToggle = getDebugToggle(page); @@ -99,9 +97,7 @@ test.describe("Settings Toggles", () => { }); test("Debug mode should persist after page reload", async ({ page }) => { - await gotoDashboardRoute(page, "/dashboard/settings"); - await waitForSettingsShell(page); - await page.getByRole("tab", { name: /advanced/i }).click(); + await gotoDashboardRoute(page, "/dashboard/system/proxy"); const debugToggle = getDebugToggle(page); @@ -113,8 +109,7 @@ test.describe("Settings Toggles", () => { const nextState = initialState === "true" ? "false" : "true"; await expect(debugToggle).toHaveAttribute("aria-checked", nextState, { timeout: 15000 }); await page.reload(); - await waitForSettingsShell(page); - await page.getByRole("tab", { name: /advanced/i }).click(); + await page.waitForLoadState("domcontentloaded"); const reloadedToggle = getDebugToggle(page); await expect(reloadedToggle).toBeEnabled({ timeout: 15000 }); await expect(reloadedToggle).toHaveAttribute("aria-checked", nextState, {